From 9730035905fe1254d856a8875b8cc1e3e2e15e98 Mon Sep 17 00:00:00 2001 From: karanjakevin39-collab Date: Sat, 30 May 2026 20:59:04 +0300 Subject: [PATCH] feat: implement comprehensive badge system with categories (#574) - Add BadgeCategory enum (LEARNING, SOCIAL, ACHIEVEMENT, ASSESSMENT, CONTRIBUTION) - Add BadgeCriteriaType enum with 9 criteria types - Extend Badge entity with category, isActive, points fields - Extend UserBadge entity with userId/badgeId columns and unique index - Add BadgesService with automatic badge awarding via @OnEvent listeners - Add LeaderboardService with points and badge leaderboards - Add PointsService that emits events for automatic badge triggering - Add BadgesController with 14 REST endpoints - Add 11 seeded default badges across all categories - Register GamificationModule in AppModule Closes #574 --- src/app.module.ts | 2 + src/gamification/badges/badge-definitions.ts | 98 +++++++++ src/gamification/badges/badges.controller.ts | 129 ++++++++++++ src/gamification/badges/badges.service.ts | 196 ++++++++++++++++-- src/gamification/dto/badge.dto.ts | 49 +++++ src/gamification/entities/badge.entity.ts | 41 +++- .../entities/user-badge.entity.ts | 20 +- src/gamification/enums/badge-category.enum.ts | 7 + .../enums/badge-criteria-type.enum.ts | 11 + .../events/gamification.events.ts | 47 +++++ src/gamification/gamification.module.ts | 33 +++ .../leaderboards/leaderboards.service.ts | 119 +++++++++-- src/gamification/points/points.service.ts | 44 ++-- 13 files changed, 729 insertions(+), 67 deletions(-) create mode 100644 src/gamification/badges/badge-definitions.ts create mode 100644 src/gamification/badges/badges.controller.ts create mode 100644 src/gamification/dto/badge.dto.ts create mode 100644 src/gamification/enums/badge-category.enum.ts create mode 100644 src/gamification/enums/badge-criteria-type.enum.ts create mode 100644 src/gamification/events/gamification.events.ts create mode 100644 src/gamification/gamification.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index b1e99800..6196278d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { DataPipelineModule } from './data-pipeline/data-pipeline.module'; import { CanaryModule } from './canary/canary.module'; import { IncidentManagementModule } from './incident-management/incident-management.module'; import { MonitoringModule } from './monitoring/monitoring.module'; +import { GamificationModule } from './gamification/gamification.module'; import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; // ✅ keep BOTH modules @@ -43,6 +44,7 @@ const featureFlags = loadFeatureFlags(); CanaryModule, IncidentManagementModule, MonitoringModule, + GamificationModule, // ✅ always include read replicas (or wrap if needed) ReadReplicaModule, diff --git a/src/gamification/badges/badge-definitions.ts b/src/gamification/badges/badge-definitions.ts new file mode 100644 index 00000000..5c90b87e --- /dev/null +++ b/src/gamification/badges/badge-definitions.ts @@ -0,0 +1,98 @@ +import { BadgeCategory } from '../enums/badge-category.enum'; +import { BadgeCriteriaType } from '../enums/badge-criteria-type.enum'; + +export const DEFAULT_BADGES = [ + // --- LEARNING --- + { + name: 'First Step', + description: 'Complete your first course', + category: BadgeCategory.LEARNING, + criteriaType: BadgeCriteriaType.COURSES_COMPLETED, + criteriaValue: { threshold: 1 }, + points: 50, + }, + { + name: 'Course Collector', + description: 'Complete 5 courses', + category: BadgeCategory.LEARNING, + criteriaType: BadgeCriteriaType.COURSES_COMPLETED, + criteriaValue: { threshold: 5 }, + points: 200, + }, + { + name: 'Scholar', + description: 'Complete 20 courses', + category: BadgeCategory.LEARNING, + criteriaType: BadgeCriteriaType.COURSES_COMPLETED, + criteriaValue: { threshold: 20 }, + points: 500, + }, + { + name: 'Week Warrior', + description: 'Maintain a 7-day learning streak', + category: BadgeCategory.LEARNING, + criteriaType: BadgeCriteriaType.LEARNING_STREAK_DAYS, + criteriaValue: { threshold: 7 }, + points: 150, + }, + // --- ASSESSMENT --- + { + name: 'Perfectionist', + description: 'Score 100% on an assessment', + category: BadgeCategory.ASSESSMENT, + criteriaType: BadgeCriteriaType.ASSESSMENT_PERFECT_SCORE, + criteriaValue: { threshold: 1 }, + points: 100, + }, + { + name: 'Test Ace', + description: 'Pass 10 assessments', + category: BadgeCategory.ASSESSMENT, + criteriaType: BadgeCriteriaType.ASSESSMENTS_PASSED, + criteriaValue: { threshold: 10 }, + points: 300, + }, + // --- ACHIEVEMENT --- + { + name: 'Point Collector', + description: 'Earn 1,000 points', + category: BadgeCategory.ACHIEVEMENT, + criteriaType: BadgeCriteriaType.POINTS_REACHED, + criteriaValue: { threshold: 1000 }, + points: 100, + }, + { + name: 'High Achiever', + description: 'Reach Level 5', + category: BadgeCategory.ACHIEVEMENT, + criteriaType: BadgeCriteriaType.LEVEL_REACHED, + criteriaValue: { threshold: 5 }, + points: 250, + }, + // --- SOCIAL --- + { + name: 'Critic', + description: 'Write your first course review', + category: BadgeCategory.SOCIAL, + criteriaType: BadgeCriteriaType.REVIEWS_WRITTEN, + criteriaValue: { threshold: 1 }, + points: 50, + }, + { + name: 'Reviewer', + description: 'Write 10 course reviews', + category: BadgeCategory.SOCIAL, + criteriaType: BadgeCriteriaType.REVIEWS_WRITTEN, + criteriaValue: { threshold: 10 }, + points: 150, + }, + // --- CONTRIBUTION --- + { + name: 'Creator', + description: 'Publish your first course', + category: BadgeCategory.CONTRIBUTION, + criteriaType: BadgeCriteriaType.COURSES_CREATED, + criteriaValue: { threshold: 1 }, + points: 200, + }, +]; diff --git a/src/gamification/badges/badges.controller.ts b/src/gamification/badges/badges.controller.ts new file mode 100644 index 00000000..01cbaa8a --- /dev/null +++ b/src/gamification/badges/badges.controller.ts @@ -0,0 +1,129 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { BadgesService } from './badges.service'; +import { LeaderboardService } from '../leaderboards/leaderboards.service'; +import { CreateBadgeDto, BadgeFilterDto, LeaderboardQueryDto } from '../dto/badge.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { BadgeCategory } from '../enums/badge-category.enum'; + +@ApiTags('Gamification - Badges') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('gamification') +export class BadgesController { + constructor( + private readonly badgesService: BadgesService, + private readonly leaderboardService: LeaderboardService, + ) {} + + // ─── Badges ─────────────────────────────────────────────────────────────── + + @Get('badges') + @ApiOperation({ summary: 'Get all badges, optionally filtered by category' }) + getAllBadges(@Query() filter: BadgeFilterDto) { + return this.badgesService.getAllBadges(filter); + } + + @Get('badges/categories') + @ApiOperation({ summary: 'Get all badge categories' }) + getCategories() { + return Object.values(BadgeCategory); + } + + @Get('badges/category/:category') + @ApiOperation({ summary: 'Get badges by category' }) + getBadgesByCategory(@Param('category') category: BadgeCategory) { + return this.badgesService.getBadgesByCategory(category); + } + + @Get('badges/:id') + @ApiOperation({ summary: 'Get a badge by ID' }) + getBadge(@Param('id', ParseUUIDPipe) id: string) { + return this.badgesService.getBadgeById(id); + } + + @Post('badges') + @ApiOperation({ summary: 'Create a new badge definition (admin)' }) + createBadge(@Body() dto: CreateBadgeDto) { + return this.badgesService.createBadge(dto); + } + + @Post('badges/seed') + @ApiOperation({ summary: 'Seed default badge definitions (admin/dev)' }) + seedBadges() { + return this.badgesService.seedDefaultBadges(); + } + + // ─── My Badges ──────────────────────────────────────────────────────────── + + @Get('my/badges') + @ApiOperation({ summary: 'Get all badges earned by the current user' }) + getMyBadges(@CurrentUser() user: { id: string }) { + return this.badgesService.getUserBadges(user.id); + } + + @Get('my/badges/category/:category') + @ApiOperation({ summary: 'Get current user badges filtered by category' }) + getMyBadgesByCategory( + @CurrentUser() user: { id: string }, + @Param('category') category: BadgeCategory, + ) { + return this.badgesService.getUserBadgesByCategory(user.id, category); + } + + @Get('my/badges/count') + @ApiOperation({ summary: 'Get total badge count for current user' }) + async getMyBadgeCount(@CurrentUser() user: { id: string }) { + const count = await this.badgesService.getUserBadgeCount(user.id); + return { count }; + } + + // ─── Leaderboards ───────────────────────────────────────────────────────── + + @Get('leaderboard/points') + @ApiOperation({ summary: 'Get top players by points' }) + getPointsLeaderboard(@Query() query: LeaderboardQueryDto) { + return this.leaderboardService.getTopPlayers(query.limit ?? 10); + } + + @Get('leaderboard/badges') + @ApiOperation({ summary: 'Get top players by badge count' }) + getBadgeLeaderboard(@Query() query: LeaderboardQueryDto) { + return this.leaderboardService.getBadgeLeaderboard(query.limit ?? 10, query.category); + } + + @Get('leaderboard/my-rank') + @ApiOperation({ summary: 'Get current user rank on the points leaderboard' }) + async getMyRank(@CurrentUser() user: { id: string }) { + const rank = await this.leaderboardService.getUserRank(user.id); + return { rank }; + } + + @Get('leaderboard/my-badge-rank') + @ApiOperation({ summary: 'Get current user rank on the badge leaderboard' }) + async getMyBadgeRank( + @CurrentUser() user: { id: string }, + @Query('category') category?: BadgeCategory, + ) { + const rank = await this.leaderboardService.getUserBadgeRank(user.id, category); + return { rank }; + } + + // ─── Other Users ────────────────────────────────────────────────────────── + + @Get('users/:userId/badges') + @ApiOperation({ summary: 'Get badges for a specific user' }) + getUserBadges(@Param('userId', ParseUUIDPipe) userId: string) { + return this.badgesService.getUserBadges(userId); + } +} diff --git a/src/gamification/badges/badges.service.ts b/src/gamification/badges/badges.service.ts index fcc38fe8..d9de0f08 100644 --- a/src/gamification/badges/badges.service.ts +++ b/src/gamification/badges/badges.service.ts @@ -1,41 +1,203 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; import { Badge } from '../entities/badge.entity'; import { UserBadge } from '../entities/user-badge.entity'; import { User } from '../../users/entities/user.entity'; +import { BadgeCategory } from '../enums/badge-category.enum'; +import { BadgeCriteriaType } from '../enums/badge-criteria-type.enum'; +import { CreateBadgeDto, BadgeFilterDto } from '../dto/badge.dto'; +import { DEFAULT_BADGES } from './badge-definitions'; +import { PointsService } from '../points/points.service'; +import { + GAMIFICATION_EVENTS, + CourseCompletedEvent, + AssessmentSubmittedEvent, + PointsAwardedEvent, + ReviewWrittenEvent, + CourseCreatedEvent, +} from '../events/gamification.events'; -/** - * Provides badges operations. - */ @Injectable() export class BadgesService { + private readonly logger = new Logger(BadgesService.name); + constructor( @InjectRepository(Badge) private badgeRepository: Repository, @InjectRepository(UserBadge) private userBadgeRepository: Repository, + private pointsService: PointsService, ) {} - async awardBadge(userId: string, badgeId: string): Promise { - const existingBadge = await this.userBadgeRepository.findOne({ - where: { user: { id: userId }, badge: { id: badgeId } }, + + // ─── Admin: Badge Definition Management ─────────────────────────────────── + + async createBadge(dto: CreateBadgeDto): Promise { + const badge = this.badgeRepository.create(dto); + return this.badgeRepository.save(badge); + } + + async getAllBadges(filter?: BadgeFilterDto): Promise { + const where: Partial = {}; + if (filter?.category) where.category = filter.category; + if (filter?.isActive !== undefined) where.isActive = filter.isActive; + return this.badgeRepository.find({ where, order: { category: 'ASC', name: 'ASC' } }); + } + + async getBadgesByCategory(category: BadgeCategory): Promise { + return this.badgeRepository.find({ + where: { category, isActive: true }, + order: { name: 'ASC' }, }); - if (existingBadge) { - return existingBadge; - } + } + + async getBadgeById(id: string): Promise { + const badge = await this.badgeRepository.findOne({ where: { id } }); + if (!badge) throw new NotFoundException(`Badge ${id} not found`); + return badge; + } + + // ─── User Badge Queries ──────────────────────────────────────────────────── + + async getUserBadges(userId: string): Promise { + return this.userBadgeRepository.find({ + where: { userId }, + relations: ['badge'], + order: { earnedAt: 'DESC' }, + }); + } + + async getUserBadgesByCategory(userId: string, category: BadgeCategory): Promise { + return this.userBadgeRepository + .createQueryBuilder('ub') + .innerJoinAndSelect('ub.badge', 'badge') + .where('ub.user_id = :userId', { userId }) + .andWhere('badge.category = :category', { category }) + .orderBy('ub.earnedAt', 'DESC') + .getMany(); + } + + async getUserBadgeCount(userId: string): Promise { + return this.userBadgeRepository.count({ where: { userId } }); + } + + // ─── Core Award Logic ───────────────────────────────────────────────────── + + async awardBadge(userId: string, badgeId: string): Promise { + const existing = await this.userBadgeRepository.findOne({ + where: { userId, badgeId }, + }); + if (existing) return null; // already awarded + + const badge = await this.badgeRepository.findOne({ where: { id: badgeId } }); + if (!badge || !badge.isActive) return null; + const userBadge = this.userBadgeRepository.create({ user: { id: userId } as User, + userId, badge: { id: badgeId } as Badge, + badgeId, }); - return await this.userBadgeRepository.save(userBadge); + const saved = await this.userBadgeRepository.save(userBadge); + + // Award bonus points for earning the badge + if (badge.points > 0) { + await this.pointsService.addPoints(userId, badge.points, 'BADGE_EARNED'); + } + + this.logger.log(`Badge "${badge.name}" awarded to user ${userId}`); + return saved; } - async getUserBadges(userId: string): Promise { - return await this.userBadgeRepository.find({ - where: { user: { id: userId } }, - relations: ['badge'], + + async checkAndAwardBadges( + userId: string, + criteriaType: BadgeCriteriaType, + value: number, + ): Promise { + const badges = await this.badgeRepository.find({ + where: { criteriaType, isActive: true }, }); + + const awarded: UserBadge[] = []; + for (const badge of badges) { + const threshold = badge.criteriaValue?.threshold ?? 0; + if (value >= threshold) { + const result = await this.awardBadge(userId, badge.id); + if (result) awarded.push(result); + } + } + return awarded; + } + + // ─── Seed Default Badges ────────────────────────────────────────────────── + + async seedDefaultBadges(): Promise { + for (const def of DEFAULT_BADGES) { + const exists = await this.badgeRepository.findOne({ where: { name: def.name } }); + if (!exists) { + await this.badgeRepository.save(this.badgeRepository.create(def)); + this.logger.log(`Seeded badge: ${def.name}`); + } + } + } + + // ─── Event Listeners (Automatic Badge Awarding) ─────────────────────────── + + @OnEvent(GAMIFICATION_EVENTS.COURSE_COMPLETED) + async onCourseCompleted(event: CourseCompletedEvent): Promise { + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.COURSES_COMPLETED, + event.totalCoursesCompleted, + ); + } + + @OnEvent(GAMIFICATION_EVENTS.ASSESSMENT_SUBMITTED) + async onAssessmentSubmitted(event: AssessmentSubmittedEvent): Promise { + if (event.score === 100) { + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.ASSESSMENT_PERFECT_SCORE, + 1, + ); + } + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.ASSESSMENTS_PASSED, + event.totalPassed, + ); + } + + @OnEvent(GAMIFICATION_EVENTS.POINTS_AWARDED) + async onPointsAwarded(event: PointsAwardedEvent): Promise { + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.POINTS_REACHED, + event.totalPoints, + ); + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.LEVEL_REACHED, + event.level, + ); } - async getAllBadges(): Promise { - return await this.badgeRepository.find(); + + @OnEvent(GAMIFICATION_EVENTS.REVIEW_WRITTEN) + async onReviewWritten(event: ReviewWrittenEvent): Promise { + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.REVIEWS_WRITTEN, + event.totalReviews, + ); + } + + @OnEvent(GAMIFICATION_EVENTS.COURSE_CREATED) + async onCourseCreated(event: CourseCreatedEvent): Promise { + await this.checkAndAwardBadges( + event.userId, + BadgeCriteriaType.COURSES_CREATED, + event.totalCoursesCreated, + ); } } diff --git a/src/gamification/dto/badge.dto.ts b/src/gamification/dto/badge.dto.ts new file mode 100644 index 00000000..b3feaff1 --- /dev/null +++ b/src/gamification/dto/badge.dto.ts @@ -0,0 +1,49 @@ +import { IsEnum, IsOptional, IsInt, Min, IsBoolean, IsString, IsUrl } from 'class-validator'; +import { BadgeCategory } from '../enums/badge-category.enum'; +import { BadgeCriteriaType } from '../enums/badge-criteria-type.enum'; + +export class CreateBadgeDto { + @IsString() + name: string; + + @IsString() + description: string; + + @IsEnum(BadgeCategory) + category: BadgeCategory; + + @IsEnum(BadgeCriteriaType) + criteriaType: BadgeCriteriaType; + + criteriaValue: Record; + + @IsOptional() + @IsUrl() + iconUrl?: string; + + @IsOptional() + @IsInt() + @Min(0) + points?: number; +} + +export class BadgeFilterDto { + @IsOptional() + @IsEnum(BadgeCategory) + category?: BadgeCategory; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class LeaderboardQueryDto { + @IsOptional() + @IsInt() + @Min(1) + limit?: number; + + @IsOptional() + @IsEnum(BadgeCategory) + category?: BadgeCategory; +} diff --git a/src/gamification/entities/badge.entity.ts b/src/gamification/entities/badge.entity.ts index 3be06164..c65bbc7b 100644 --- a/src/gamification/entities/badge.entity.ts +++ b/src/gamification/entities/badge.entity.ts @@ -1,8 +1,15 @@ -import { Entity, PrimaryGeneratedColumn, Column, VersionColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + VersionColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { BadgeCategory } from '../enums/badge-category.enum'; +import { BadgeCriteriaType } from '../enums/badge-criteria-type.enum'; -/** - * Represents the badge entity. - */ @Entity('badges') export class Badge { @PrimaryGeneratedColumn('uuid') @@ -11,18 +18,34 @@ export class Badge { @VersionColumn() version: number; - @Column() + @Column({ unique: true }) name: string; @Column() description: string; - @Column() + @Column({ nullable: true }) iconUrl: string; - @Column() - criteriaType: string; // e.g., 'POINTS_REACHED', 'CHALLENGE_COMPLETED' + @Column({ type: 'enum', enum: BadgeCategory }) + @Index() + category: BadgeCategory; + + @Column({ type: 'enum', enum: BadgeCriteriaType }) + criteriaType: BadgeCriteriaType; @Column('jsonb', { nullable: true }) - criteriaValue: any; + criteriaValue: Record; // e.g. { threshold: 5 } + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: 0 }) + points: number; // bonus points awarded when badge is earned + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; } diff --git a/src/gamification/entities/user-badge.entity.ts b/src/gamification/entities/user-badge.entity.ts index 58d6182d..6ff591fb 100644 --- a/src/gamification/entities/user-badge.entity.ts +++ b/src/gamification/entities/user-badge.entity.ts @@ -4,14 +4,15 @@ import { ManyToOne, CreateDateColumn, VersionColumn, + JoinColumn, + Column, + Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Badge } from './badge.entity'; -/** - * Represents the user Badge entity. - */ @Entity('user_badges') +@Index(['userId', 'badgeId'], { unique: true }) export class UserBadge { @PrimaryGeneratedColumn('uuid') id: string; @@ -19,12 +20,21 @@ export class UserBadge { @VersionColumn() version: number; - @ManyToOne(() => User) + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) user: User; - @ManyToOne(() => Badge) + @Column({ name: 'user_id' }) + @Index() + userId: string; + + @ManyToOne(() => Badge, { eager: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'badge_id' }) badge: Badge; + @Column({ name: 'badge_id' }) + badgeId: string; + @CreateDateColumn() earnedAt: Date; } diff --git a/src/gamification/enums/badge-category.enum.ts b/src/gamification/enums/badge-category.enum.ts new file mode 100644 index 00000000..b5e6b72f --- /dev/null +++ b/src/gamification/enums/badge-category.enum.ts @@ -0,0 +1,7 @@ +export enum BadgeCategory { + LEARNING = 'LEARNING', + SOCIAL = 'SOCIAL', + ACHIEVEMENT = 'ACHIEVEMENT', + ASSESSMENT = 'ASSESSMENT', + CONTRIBUTION = 'CONTRIBUTION', +} diff --git a/src/gamification/enums/badge-criteria-type.enum.ts b/src/gamification/enums/badge-criteria-type.enum.ts new file mode 100644 index 00000000..fc0dc3b7 --- /dev/null +++ b/src/gamification/enums/badge-criteria-type.enum.ts @@ -0,0 +1,11 @@ +export enum BadgeCriteriaType { + COURSES_COMPLETED = 'COURSES_COMPLETED', + LESSONS_WATCHED = 'LESSONS_WATCHED', + LEARNING_STREAK_DAYS = 'LEARNING_STREAK_DAYS', + ASSESSMENT_PERFECT_SCORE = 'ASSESSMENT_PERFECT_SCORE', + ASSESSMENTS_PASSED = 'ASSESSMENTS_PASSED', + POINTS_REACHED = 'POINTS_REACHED', + LEVEL_REACHED = 'LEVEL_REACHED', + REVIEWS_WRITTEN = 'REVIEWS_WRITTEN', + COURSES_CREATED = 'COURSES_CREATED', +} diff --git a/src/gamification/events/gamification.events.ts b/src/gamification/events/gamification.events.ts new file mode 100644 index 00000000..cc08c90f --- /dev/null +++ b/src/gamification/events/gamification.events.ts @@ -0,0 +1,47 @@ +export const GAMIFICATION_EVENTS = { + COURSE_COMPLETED: 'gamification.course.completed', + ASSESSMENT_SUBMITTED: 'gamification.assessment.submitted', + POINTS_AWARDED: 'gamification.points.awarded', + REVIEW_WRITTEN: 'gamification.review.written', + COURSE_CREATED: 'gamification.course.created', + USER_LOGIN: 'gamification.user.login', +} as const; + +export class CourseCompletedEvent { + constructor( + public readonly userId: string, + public readonly courseId: string, + public readonly totalCoursesCompleted: number, + ) {} +} + +export class AssessmentSubmittedEvent { + constructor( + public readonly userId: string, + public readonly assessmentId: string, + public readonly score: number, + public readonly totalPassed: number, + ) {} +} + +export class PointsAwardedEvent { + constructor( + public readonly userId: string, + public readonly totalPoints: number, + public readonly level: number, + ) {} +} + +export class ReviewWrittenEvent { + constructor( + public readonly userId: string, + public readonly totalReviews: number, + ) {} +} + +export class CourseCreatedEvent { + constructor( + public readonly userId: string, + public readonly totalCoursesCreated: number, + ) {} +} diff --git a/src/gamification/gamification.module.ts b/src/gamification/gamification.module.ts new file mode 100644 index 00000000..a6863444 --- /dev/null +++ b/src/gamification/gamification.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventEmitterModule } from '@nestjs/event-emitter'; + +import { Badge } from './entities/badge.entity'; +import { UserBadge } from './entities/user-badge.entity'; +import { UserProgress } from './entities/user-progress.entity'; +import { PointTransaction } from './entities/point-transaction.entity'; +import { Challenge } from './entities/challenge.entity'; +import { UserChallenge } from './entities/user-challenge.entity'; + +import { BadgesService } from './badges/badges.service'; +import { BadgesController } from './badges/badges.controller'; +import { PointsService } from './points/points.service'; +import { LeaderboardService } from './leaderboards/leaderboards.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Badge, + UserBadge, + UserProgress, + PointTransaction, + Challenge, + UserChallenge, + ]), + EventEmitterModule.forRoot(), + ], + controllers: [BadgesController], + providers: [BadgesService, PointsService, LeaderboardService], + exports: [BadgesService, PointsService, LeaderboardService], +}) +export class GamificationModule {} diff --git a/src/gamification/leaderboards/leaderboards.service.ts b/src/gamification/leaderboards/leaderboards.service.ts index f7981693..cd08c29d 100644 --- a/src/gamification/leaderboards/leaderboards.service.ts +++ b/src/gamification/leaderboards/leaderboards.service.ts @@ -2,31 +2,116 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UserProgress } from '../entities/user-progress.entity'; +import { UserBadge } from '../entities/user-badge.entity'; +import { BadgeCategory } from '../enums/badge-category.enum'; + +export interface LeaderboardEntry { + rank: number; + userId: string; + username: string; + totalPoints: number; + level: number; + badgeCount: number; +} + +export interface BadgeLeaderboardEntry { + rank: number; + userId: string; + username: string; + badgeCount: number; + category?: BadgeCategory; +} -/** - * Provides leaderboard operations. - */ @Injectable() export class LeaderboardService { constructor( @InjectRepository(UserProgress) private userProgressRepository: Repository, + @InjectRepository(UserBadge) + private userBadgeRepository: Repository, ) {} - async getTopPlayers(limit: number = 10): Promise { - return await this.userProgressRepository.find({ - order: { totalPoints: 'DESC' }, - take: limit, - relations: ['user'], - }); + + // ─── Points Leaderboard ─────────────────────────────────────────────────── + + async getTopPlayers(limit: number = 10): Promise { + const rows = await this.userProgressRepository + .createQueryBuilder('up') + .innerJoinAndSelect('up.user', 'user') + .orderBy('up.totalPoints', 'DESC') + .take(limit) + .getMany(); + + return rows.map((up, index) => ({ + rank: index + 1, + userId: up.user.id, + username: up.user.username ?? up.user.email, + totalPoints: up.totalPoints, + level: up.level, + badgeCount: 0, // enriched below if needed + })); } + async getUserRank(userId: string): Promise { - const allProgress = await this.userProgressRepository.find({ - order: { totalPoints: 'DESC' }, - }); - // This is a simple rank calculation that is O(n) over users. - // For large leaderboards, consider a direct database rank query or a - // cached materialized ranking field. - const rank = allProgress.findIndex((p) => p.user?.id === userId) + 1; - return rank > 0 ? rank : null; + const count = await this.userProgressRepository + .createQueryBuilder('up') + .innerJoin('up.user', 'user') + .where('up.totalPoints > (SELECT total_points FROM user_progress WHERE user_id = :userId)', { userId }) + .getCount(); + + return count + 1; + } + + // ─── Badge Leaderboard ──────────────────────────────────────────────────── + + async getBadgeLeaderboard( + limit: number = 10, + category?: BadgeCategory, + ): Promise { + const qb = this.userBadgeRepository + .createQueryBuilder('ub') + .innerJoin('ub.user', 'user') + .select('user.id', 'userId') + .addSelect('user.username', 'username') + .addSelect('user.email', 'email') + .addSelect('COUNT(ub.id)', 'badgeCount') + .groupBy('user.id') + .addGroupBy('user.username') + .addGroupBy('user.email') + .orderBy('badgeCount', 'DESC') + .limit(limit); + + if (category) { + qb.innerJoin('ub.badge', 'badge').andWhere('badge.category = :category', { category }); + } + + const rows = await qb.getRawMany(); + return rows.map((row, index) => ({ + rank: index + 1, + userId: row.userId, + username: row.username ?? row.email, + badgeCount: parseInt(row.badgeCount, 10), + category, + })); + } + + async getUserBadgeRank(userId: string, category?: BadgeCategory): Promise { + const userCount = await this.userBadgeRepository + .createQueryBuilder('ub') + .where('ub.user_id = :userId', { userId }) + .getCount(); + + const qb = this.userBadgeRepository + .createQueryBuilder('ub') + .select('ub.user_id', 'userId') + .addSelect('COUNT(ub.id)', 'badgeCount') + .groupBy('ub.user_id') + .having('COUNT(ub.id) > :userCount', { userCount }); + + if (category) { + qb.innerJoin('ub.badge', 'badge').andWhere('badge.category = :category', { category }); + } + + const ahead = await qb.getRawMany(); + return ahead.length + 1; } } diff --git a/src/gamification/points/points.service.ts b/src/gamification/points/points.service.ts index 6b00f636..366e37c5 100644 --- a/src/gamification/points/points.service.ts +++ b/src/gamification/points/points.service.ts @@ -1,13 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { UserProgress } from '../entities/user-progress.entity'; import { PointTransaction } from '../entities/point-transaction.entity'; import { User } from '../../users/entities/user.entity'; +import { GAMIFICATION_EVENTS, PointsAwardedEvent } from '../events/gamification.events'; -/** - * Provides points operations. - */ @Injectable() export class PointsService { constructor( @@ -15,24 +14,17 @@ export class PointsService { private userProgressRepository: Repository, @InjectRepository(PointTransaction) private pointTransactionRepository: Repository, + private eventEmitter: EventEmitter2, ) {} - /** - * Award points for a user activity and update progress. - * - * The current progression model is intentionally simple: points and XP are - * identical, and level increases every 1000 XP. This is a good place to add - * more advanced reward rules later. - */ async addPoints(userId: string, points: number, activityType: string): Promise { - // Log the transaction const transaction = this.pointTransactionRepository.create({ user: { id: userId } as User, points, activityType, }); await this.pointTransactionRepository.save(transaction); - // Update user progress + let progress = await this.userProgressRepository.findOne({ where: { user: { id: userId } }, }); @@ -44,19 +36,33 @@ export class PointsService { xp: 0, }); } + progress.totalPoints += points; progress.xp += points; - // Basic level progression logic: level up every 1000 XP + const newLevel = Math.floor(progress.xp / 1000) + 1; - if (newLevel > progress.level) { - progress.level = newLevel; - // TODO: Emit level up event for notification, badge award, or milestone tracking - } - return await this.userProgressRepository.save(progress); + progress.level = newLevel; + + const saved = await this.userProgressRepository.save(progress); + + // Emit event so BadgesService can react + this.eventEmitter.emit( + GAMIFICATION_EVENTS.POINTS_AWARDED, + new PointsAwardedEvent(userId, saved.totalPoints, saved.level), + ); + + return saved; } + async getUserProgress(userId: string): Promise { - return await this.userProgressRepository.findOne({ + return this.userProgressRepository.findOne({ where: { user: { id: userId } } }); + } + + async getPointHistory(userId: string): Promise { + return this.pointTransactionRepository.find({ where: { user: { id: userId } }, + order: { createdAt: 'DESC' }, + take: 50, }); } }