From 08276743705c615d7e60c4529d91c2ea2cc504c9 Mon Sep 17 00:00:00 2001 From: Fayvor22 Date: Tue, 2 Jun 2026 18:03:37 +0000 Subject: [PATCH] feat: Implement achieve system with progression tracking --- src/achievements/README.md | 399 +++++++++++ .../__tests__/achievements.controller.spec.ts | 239 +++++++ .../__tests__/achievements.service.spec.ts | 395 ++++++++++ .../achievements-notifications.service.ts | 130 ++++ src/achievements/achievements.controller.ts | 291 ++++++++ .../achievements.integration.example.ts | 283 ++++++++ src/achievements/achievements.module.ts | 26 + src/achievements/achievements.seed.ts | 320 +++++++++ src/achievements/achievements.service.ts | 672 ++++++++++++++++++ src/achievements/achievements.types.ts | 185 +++++ .../dto/achievement-progress.dto.ts | 239 +++++++ .../dto/achievement-statistics.dto.ts | 33 + src/achievements/dto/achievement.dto.ts | 48 ++ src/achievements/dto/user-achievement.dto.ts | 25 + .../entities/achievement-progress.entity.ts | 82 +++ .../entities/achievement-statistics.entity.ts | 88 +++ .../entities/achievement.entity.ts | 104 +++ .../entities/user-achievement.entity.ts | 83 +++ .../1700000000000-CreateAchievementsSchema.ts | 458 ++++++++++++ src/app.module.ts | 3 + 20 files changed, 4103 insertions(+) create mode 100644 src/achievements/README.md create mode 100644 src/achievements/__tests__/achievements.controller.spec.ts create mode 100644 src/achievements/__tests__/achievements.service.spec.ts create mode 100644 src/achievements/achievements-notifications.service.ts create mode 100644 src/achievements/achievements.controller.ts create mode 100644 src/achievements/achievements.integration.example.ts create mode 100644 src/achievements/achievements.module.ts create mode 100644 src/achievements/achievements.seed.ts create mode 100644 src/achievements/achievements.service.ts create mode 100644 src/achievements/achievements.types.ts create mode 100644 src/achievements/dto/achievement-progress.dto.ts create mode 100644 src/achievements/dto/achievement-statistics.dto.ts create mode 100644 src/achievements/dto/achievement.dto.ts create mode 100644 src/achievements/dto/user-achievement.dto.ts create mode 100644 src/achievements/entities/achievement-progress.entity.ts create mode 100644 src/achievements/entities/achievement-statistics.entity.ts create mode 100644 src/achievements/entities/achievement.entity.ts create mode 100644 src/achievements/entities/user-achievement.entity.ts create mode 100644 src/achievements/migrations/1700000000000-CreateAchievementsSchema.ts diff --git a/src/achievements/README.md b/src/achievements/README.md new file mode 100644 index 0000000..0f77007 --- /dev/null +++ b/src/achievements/README.md @@ -0,0 +1,399 @@ +# Achievement System + +A comprehensive achievement and progression tracking system for TeachLink that allows users to unlock achievements, track progress, and compete on leaderboards. + +## Features + +### 1. **Achievement Definition System** +- Create and manage achievement definitions with various types and difficulties +- Support for multiple achievement types: Milestone, Challenge, Streaks, Skill-based, Engagement, Contribution +- Difficulty levels: Easy, Medium, Hard, Legendary +- Flexible criteria configuration for different achievement conditions +- Points and experience rewards per achievement + +### 2. **Progress Tracking** +- Track user progress toward achievements with incremental progress +- Automatic unlock when targets are reached +- Percentage-based progress visualization +- Metadata support for additional context +- Progress history and last update tracking + +### 3. **Achievement Notifications** +- Automatic notifications when achievements are unlocked +- Points and XP earned information +- Customizable notification messages +- Failed notification retry mechanisms + +### 4. **Statistics and Analytics** +- Achievement unlock rates and trends +- User achievement leaderboards +- Achievement overview per user +- Daily statistics collection +- Engagement trend analysis +- Average time to unlock calculations + +## Database Schema + +### Entities + +#### `Achievement` +Main achievement definition entity. + +```typescript +{ + id: UUID + name: string + description: string + longDescription?: string + iconUrl: string + type: 'milestone' | 'challenge' | 'streaks' | 'skill_based' | 'engagement' | 'contribution' + difficulty: 'easy' | 'medium' | 'hard' | 'legendary' + pointsReward: number + experienceReward: number + criteria: JSONB // { type: string, target: number, ... } + progressConfig: JSONB // { trackingType: string, maxProgress: number } + isActive: boolean + isHidden: boolean + unlockedBy: number // Count of users who unlocked + createdAt: timestamp + updatedAt: timestamp +} +``` + +#### `AchievementProgress` +Tracks a user's progress toward an achievement. + +```typescript +{ + id: UUID + userId: UUID + achievementId: UUID + currentProgress: number + targetProgress: number + percentageComplete: number (0-100) + isUnlocked: boolean + lastProgressUpdate?: timestamp + metadata?: JSONB + createdAt: timestamp + updatedAt: timestamp +} +``` + +#### `UserAchievement` +Records when a user unlocks an achievement. + +```typescript +{ + id: UUID + userId: UUID + achievementId: UUID + unlockedAt: timestamp + unlockedMetadata?: JSONB + pointsEarned: number + experienceEarned: number + notificationSent: boolean + isHidden: boolean + createdAt: timestamp + updatedAt: timestamp +} +``` + +#### `AchievementStatistics` +Daily statistics for achievements. + +```typescript +{ + id: UUID + achievementId: UUID + date: date + totalUnlocked: number + unlockedToday: number + unlockedPercentage: number + averageTimeToUnlock?: number (days) + activeTrackers: number + averageProgress: number + engagementTrend?: 'positive' | 'negative' | 'stable' + metadata?: JSONB + createdAt: timestamp + updatedAt: timestamp +} +``` + +## API Endpoints + +### Achievement Management + +#### Create Achievement +``` +POST /achievements +Body: { + name: string + description: string + longDescription?: string + iconUrl: string + type: AchievementType + difficulty: AchievementDifficulty + pointsReward: number + experienceReward: number + criteria: object + progressConfig: object +} +Response: AchievementResponseDto +``` + +#### Get All Achievements +``` +GET /achievements?includeHidden=false +Response: AchievementResponseDto[] +``` + +#### Get Achievement by ID +``` +GET /achievements/:achievementId +Response: AchievementResponseDto +``` + +#### Get Achievements by Type +``` +GET /achievements/type/:type +Response: AchievementResponseDto[] +``` + +#### Update Achievement +``` +PUT /achievements/:achievementId +Body: Partial +Response: AchievementResponseDto +``` + +#### Deactivate Achievement +``` +DELETE /achievements/:achievementId +Response: 204 No Content +``` + +### Progress Tracking + +#### Initialize Progress +``` +POST /achievements/:achievementId/progress/:userId +Response: AchievementProgressDto +``` + +#### Get User Progress for Achievement +``` +GET /achievements/:achievementId/progress/:userId +Response: AchievementProgressDto +``` + +#### Update Progress +``` +PUT /achievements/:achievementId/progress/:userId +Body: { + currentProgress: number + metadata?: object +} +Response: AchievementProgressDto +``` + +#### Increment Progress +``` +POST /achievements/:achievementId/progress/:userId/increment +Body: { + incrementBy?: number (default: 1) + metadata?: object +} +Response: AchievementProgressDto +``` + +#### Get All User Progress +``` +GET /achievements/progress/:userId +Response: AchievementProgressDto[] +``` + +### Achievement Unlocking + +#### Unlock Achievement +``` +POST /achievements/:achievementId/unlock/:userId +Body?: { metadata?: object } +Response: AchievementUnlockedEventDto +``` + +#### Get User Achievements +``` +GET /achievements/user/:userId/unlocked +Response: UserAchievementDto[] +``` + +#### Check If User Has Achievement +``` +GET /achievements/:achievementId/user/:userId/has +Response: { hasAchievement: boolean } +``` + +#### Get User Achievement Count +``` +GET /achievements/user/:userId/count +Response: { count: number } +``` + +#### Batch Unlock Achievements +``` +POST /achievements/batch-unlock/:userId +Body: { achievementIds: string[] } +Response: AchievementUnlockedEventDto[] +``` + +### Statistics & Analytics + +#### Get Achievement Statistics +``` +GET /achievements/:achievementId/statistics +Response: AchievementStatisticsDto +``` + +#### Get User Achievement Overview +``` +GET /achievements/user/:userId/overview +Response: AchievementOverviewDto +{ + totalAchievements: number + unlockedAchievements: number + progressPercentage: number + totalPointsEarned: number + totalExperienceEarned: number + userRank: number +} +``` + +#### Get Achievements Leaderboard +``` +GET /achievements/leaderboard?limit=10 +Response: AchievementLeaderboardDto[] +``` + +#### Get All Statistics +``` +GET /achievements/statistics/all +Response: AchievementStatisticsDto[] +``` + +## Usage Examples + +### Creating an Achievement + +```typescript +const achievement = await achievementsService.createAchievement({ + name: 'Course Master', + description: 'Complete 10 courses', + longDescription: 'Demonstrates dedication to learning', + iconUrl: 'https://example.com/icons/course-master.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.HARD, + pointsReward: 500, + experienceReward: 250, + criteria: { + type: 'COURSES_COMPLETED', + target: 10 + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 10 + } +}); +``` + +### Tracking Progress + +```typescript +// Initialize progress tracking +await achievementsService.initializeProgress(userId, achievementId); + +// Increment progress (e.g., when course is completed) +await achievementsService.incrementProgress(userId, achievementId, 1, { + courseId: 'course-123', + courseName: 'Advanced TypeScript' +}); + +// Get progress +const progress = await achievementsService.getUserProgressForAchievement(userId, achievementId); +console.log(`Progress: ${progress.percentageComplete}%`); +``` + +### Unlocking Achievements + +```typescript +// Manual unlock +const unlockedEvent = await achievementsService.unlockAchievement(userId, achievementId, { + reason: 'course_completion', + courseId: 'course-123' +}); + +console.log(`Earned ${unlockedEvent.pointsEarned} points!`); + +// Check if user has achievement +const hasAchievement = await achievementsService.hasAchievement(userId, achievementId); +``` + +### Getting Statistics + +```typescript +// User overview +const overview = await achievementsService.getUserAchievementOverview(userId); +console.log(`User has unlocked ${overview.unlockedAchievements} of ${overview.totalAchievements} achievements`); + +// Leaderboard +const leaderboard = await achievementsService.getAchievementsLeaderboard(10); +console.log('Top achievement unlocking users:', leaderboard); + +// Achievement statistics +const stats = await achievementsService.getAchievementStatistics(achievementId); +console.log(`${stats.totalUnlocked} users have this achievement`); +``` + +## Integration Points + +### With Notifications +The achievements system is designed to integrate with the existing notifications module. When an achievement is unlocked, a notification is automatically created. + +### With User Progression +Achievements track user progression and contribute to overall user engagement metrics. + +### With Gamification +Works alongside the existing gamification module for badges and points. + +## Testing + +Run tests with: +```bash +npm run test -- src/achievements +``` + +Test coverage includes: +- Achievement CRUD operations +- Progress tracking logic +- Auto-unlock functionality +- Statistics calculations +- Leaderboard generation + +## Best Practices + +1. **Define Clear Criteria**: Make sure achievement criteria are unambiguous and measurable +2. **Balance Difficulty**: Mix easy, medium, hard, and legendary achievements +3. **Regular Cleanup**: Deactivate outdated achievements instead of deleting them +4. **Monitor Stats**: Use statistics to identify if achievements are well-tuned +5. **Reward Appropriately**: Align points/XP rewards with achievement difficulty +6. **Use Metadata**: Store context about how achievements are earned for analytics + +## Future Enhancements + +- [ ] Achievement categories and sub-categories +- [ ] Time-limited/seasonal achievements +- [ ] Achievement cascades (unlock achievement A to unlock B) +- [ ] Team/group achievements +- [ ] Achievement badges with tiers +- [ ] Notifications integration with email/push +- [ ] Achievement recommendations +- [ ] Custom achievement editor UI diff --git a/src/achievements/__tests__/achievements.controller.spec.ts b/src/achievements/__tests__/achievements.controller.spec.ts new file mode 100644 index 0000000..7f18732 --- /dev/null +++ b/src/achievements/__tests__/achievements.controller.spec.ts @@ -0,0 +1,239 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AchievementsController } from '../achievements.controller'; +import { AchievementsService } from '../achievements.service'; +import { Achievement, AchievementType, AchievementDifficulty } from '../entities/achievement.entity'; +import { AchievementProgress } from '../entities/achievement-progress.entity'; +import { UserAchievement } from '../entities/user-achievement.entity'; +import { AchievementStatistics } from '../entities/achievement-statistics.entity'; + +describe('AchievementsController', () => { + let controller: AchievementsController; + let service: AchievementsService; + + const mockAchievementResponseDto = { + id: 'ach-123', + name: 'First Steps', + description: 'Complete your first lesson', + longDescription: 'A detailed description', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + isActive: true, + isHidden: false, + unlockedBy: 5, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AchievementsController], + providers: [ + { + provide: AchievementsService, + useValue: { + createAchievement: jest.fn(), + getAllAchievements: jest.fn(), + getAchievementsByType: jest.fn(), + getAchievementById: jest.fn(), + updateAchievement: jest.fn(), + deactivateAchievement: jest.fn(), + initializeProgress: jest.fn(), + getUserProgressForAchievement: jest.fn(), + updateProgress: jest.fn(), + incrementProgress: jest.fn(), + getUserAllProgress: jest.fn(), + unlockAchievement: jest.fn(), + getUserAchievements: jest.fn(), + hasAchievement: jest.fn(), + getUserAchievementCount: jest.fn(), + getAchievementStatistics: jest.fn(), + getUserAchievementOverview: jest.fn(), + getAchievementsLeaderboard: jest.fn(), + getAllAchievementsStatistics: jest.fn(), + batchUnlockAchievements: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AchievementsController); + service = module.get(AchievementsService); + }); + + describe('createAchievement', () => { + it('should create an achievement', async () => { + const createDto = { + name: 'First Steps', + description: 'Complete your first lesson', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + }; + + jest.spyOn(service, 'createAchievement').mockResolvedValue(mockAchievementResponseDto); + + const result = await controller.createAchievement(createDto); + + expect(result).toEqual(mockAchievementResponseDto); + expect(service.createAchievement).toHaveBeenCalledWith(createDto); + }); + }); + + describe('getAllAchievements', () => { + it('should get all achievements', async () => { + jest.spyOn(service, 'getAllAchievements').mockResolvedValue([mockAchievementResponseDto]); + + const result = await controller.getAllAchievements(); + + expect(result).toEqual([mockAchievementResponseDto]); + expect(service.getAllAchievements).toHaveBeenCalledWith(false); + }); + }); + + describe('getAchievementById', () => { + it('should get achievement by id', async () => { + jest.spyOn(service, 'getAchievementById').mockResolvedValue(mockAchievementResponseDto); + + const result = await controller.getAchievementById('ach-123'); + + expect(result).toEqual(mockAchievementResponseDto); + expect(service.getAchievementById).toHaveBeenCalledWith('ach-123'); + }); + }); + + describe('updateAchievement', () => { + it('should update an achievement', async () => { + const updateDto = { name: 'Updated Name' }; + const updatedResponse = { ...mockAchievementResponseDto, name: 'Updated Name' }; + + jest.spyOn(service, 'updateAchievement').mockResolvedValue(updatedResponse); + + const result = await controller.updateAchievement('ach-123', updateDto); + + expect(result.name).toBe('Updated Name'); + expect(service.updateAchievement).toHaveBeenCalledWith('ach-123', updateDto); + }); + }); + + describe('unlockAchievement', () => { + it('should unlock an achievement', async () => { + const mockUnlockedEvent = { + userId: 'user-123', + achievementId: 'ach-123', + achievement: mockAchievementResponseDto, + pointsEarned: 100, + experienceEarned: 50, + unlockedAt: new Date(), + }; + + jest.spyOn(service, 'unlockAchievement').mockResolvedValue(mockUnlockedEvent); + + const result = await controller.unlockAchievement('ach-123', 'user-123'); + + expect(result.userId).toBe('user-123'); + expect(result.achievementId).toBe('ach-123'); + expect(service.unlockAchievement).toHaveBeenCalledWith('user-123', 'ach-123', undefined); + }); + }); + + describe('getUserAchievements', () => { + it('should get user achievements', async () => { + const mockUserAchievements = [ + { + id: 'ua-1', + userId: 'user-123', + achievementId: 'ach-123', + achievement: mockAchievementResponseDto, + unlockedAt: new Date(), + pointsEarned: 100, + experienceEarned: 50, + notificationSent: true, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + jest.spyOn(service, 'getUserAchievements').mockResolvedValue(mockUserAchievements); + + const result = await controller.getUserAchievements('user-123'); + + expect(result).toEqual(mockUserAchievements); + expect(service.getUserAchievements).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('hasAchievement', () => { + it('should check if user has achievement', async () => { + jest.spyOn(service, 'hasAchievement').mockResolvedValue(true); + + const result = await controller.hasAchievement('ach-123', 'user-123'); + + expect(result).toEqual({ hasAchievement: true }); + expect(service.hasAchievement).toHaveBeenCalledWith('user-123', 'ach-123'); + }); + }); + + describe('getUserAchievementCount', () => { + it('should get user achievement count', async () => { + jest.spyOn(service, 'getUserAchievementCount').mockResolvedValue(5); + + const result = await controller.getUserAchievementCount('user-123'); + + expect(result).toEqual({ count: 5 }); + expect(service.getUserAchievementCount).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('getAchievementsLeaderboard', () => { + it('should get achievements leaderboard', async () => { + const mockLeaderboard = [ + { + userId: 'user-1', + username: 'user1', + totalAchievements: 10, + totalPoints: 1000, + totalExperience: 500, + rank: 1, + }, + ]; + + jest.spyOn(service, 'getAchievementsLeaderboard').mockResolvedValue(mockLeaderboard); + + const result = await controller.getAchievementsLeaderboard('10'); + + expect(result).toEqual(mockLeaderboard); + expect(service.getAchievementsLeaderboard).toHaveBeenCalledWith(10); + }); + }); + + describe('getUserAchievementOverview', () => { + it('should get user achievement overview', async () => { + const mockOverview = { + totalAchievements: 10, + unlockedAchievements: 5, + progressPercentage: 50, + totalPointsEarned: 500, + totalExperienceEarned: 250, + userRank: 15, + }; + + jest.spyOn(service, 'getUserAchievementOverview').mockResolvedValue(mockOverview); + + const result = await controller.getUserAchievementOverview('user-123'); + + expect(result).toEqual(mockOverview); + expect(service.getUserAchievementOverview).toHaveBeenCalledWith('user-123'); + }); + }); +}); diff --git a/src/achievements/__tests__/achievements.service.spec.ts b/src/achievements/__tests__/achievements.service.spec.ts new file mode 100644 index 0000000..2dce340 --- /dev/null +++ b/src/achievements/__tests__/achievements.service.spec.ts @@ -0,0 +1,395 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AchievementsService } from '../achievements.service'; +import { Achievement, AchievementType, AchievementDifficulty } from '../entities/achievement.entity'; +import { AchievementProgress } from '../entities/achievement-progress.entity'; +import { UserAchievement } from '../entities/user-achievement.entity'; +import { AchievementStatistics } from '../entities/achievement-statistics.entity'; +import { User } from '../../users/entities/user.entity'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +describe('AchievementsService', () => { + let service: AchievementsService; + let achievementRepo: Repository; + let progressRepo: Repository; + let userAchievementRepo: Repository; + let statisticsRepo: Repository; + + const mockUser: User = { + id: 'user-123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'hashed', + } as User; + + const mockAchievement: Achievement = { + id: 'ach-123', + name: 'First Steps', + description: 'Complete your first lesson', + longDescription: 'A detailed description', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + isActive: true, + isHidden: false, + unlockedBy: 5, + createdAt: new Date(), + updatedAt: new Date(), + } as Achievement; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AchievementsService, + { + provide: getRepositoryToken(Achievement), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + increment: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AchievementProgress), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserAchievement), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AchievementStatistics), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AchievementsService); + achievementRepo = module.get>(getRepositoryToken(Achievement)); + progressRepo = module.get>( + getRepositoryToken(AchievementProgress), + ); + userAchievementRepo = module.get>( + getRepositoryToken(UserAchievement), + ); + statisticsRepo = module.get>( + getRepositoryToken(AchievementStatistics), + ); + }); + + describe('createAchievement', () => { + it('should create a new achievement', async () => { + const createDto = { + name: 'First Steps', + description: 'Complete your first lesson', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + }; + + jest.spyOn(achievementRepo, 'create').mockReturnValue(mockAchievement); + jest.spyOn(achievementRepo, 'save').mockResolvedValue(mockAchievement); + + const result = await service.createAchievement(createDto); + + expect(result).toEqual({ + id: mockAchievement.id, + name: mockAchievement.name, + description: mockAchievement.description, + iconUrl: mockAchievement.iconUrl, + type: mockAchievement.type, + difficulty: mockAchievement.difficulty, + pointsReward: mockAchievement.pointsReward, + experienceReward: mockAchievement.experienceReward, + criteria: mockAchievement.criteria, + progressConfig: mockAchievement.progressConfig, + isActive: mockAchievement.isActive, + isHidden: mockAchievement.isHidden, + unlockedBy: mockAchievement.unlockedBy, + createdAt: mockAchievement.createdAt, + updatedAt: mockAchievement.updatedAt, + }); + expect(achievementRepo.create).toHaveBeenCalled(); + expect(achievementRepo.save).toHaveBeenCalled(); + }); + }); + + describe('getAchievementById', () => { + it('should get an achievement by ID', async () => { + jest.spyOn(achievementRepo, 'findOne').mockResolvedValue(mockAchievement); + + const result = await service.getAchievementById('ach-123'); + + expect(result.id).toBe(mockAchievement.id); + expect(result.name).toBe(mockAchievement.name); + }); + + it('should throw NotFoundException if achievement not found', async () => { + jest.spyOn(achievementRepo, 'findOne').mockResolvedValue(null); + + expect(service.getAchievementById('ach-999')).rejects.toThrow(NotFoundException); + }); + }); + + describe('unlockAchievement', () => { + it('should unlock an achievement for a user', async () => { + const mockUserAchievement: UserAchievement = { + id: 'ua-123', + user: mockUser, + achievement: mockAchievement, + unlockedAt: new Date(), + pointsEarned: mockAchievement.pointsReward, + experienceEarned: mockAchievement.experienceReward, + notificationSent: false, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + } as UserAchievement; + + jest.spyOn(userAchievementRepo, 'findOne').mockResolvedValue(null); + jest.spyOn(achievementRepo, 'findOne').mockResolvedValue(mockAchievement); + jest.spyOn(userAchievementRepo, 'create').mockReturnValue(mockUserAchievement); + jest.spyOn(userAchievementRepo, 'save').mockResolvedValue(mockUserAchievement); + jest.spyOn(progressRepo, 'update').mockResolvedValue({ affected: 1 } as any); + jest.spyOn(achievementRepo, 'increment').mockResolvedValue({ affected: 1 } as any); + + const result = await service.unlockAchievement('user-123', 'ach-123'); + + expect(result.userId).toBe('user-123'); + expect(result.achievementId).toBe('ach-123'); + expect(result.pointsEarned).toBe(mockAchievement.pointsReward); + expect(result.experienceEarned).toBe(mockAchievement.experienceReward); + }); + + it('should not duplicate unlocked achievement', async () => { + const mockUserAchievement: UserAchievement = { + id: 'ua-123', + user: mockUser, + achievement: mockAchievement, + unlockedAt: new Date(), + pointsEarned: mockAchievement.pointsReward, + experienceEarned: mockAchievement.experienceReward, + notificationSent: false, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + } as UserAchievement; + + jest.spyOn(userAchievementRepo, 'findOne').mockResolvedValue(mockUserAchievement); + + const result = await service.unlockAchievement('user-123', 'ach-123'); + + expect(result.userId).toBe('user-123'); + expect(userAchievementRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe('updateProgress', () => { + it('should update achievement progress', async () => { + const mockProgress: AchievementProgress = { + id: 'prog-123', + user: mockUser, + achievement: mockAchievement, + currentProgress: 0, + targetProgress: 100, + percentageComplete: 0, + isUnlocked: false, + lastProgressUpdate: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + } as AchievementProgress; + + jest.spyOn(progressRepo, 'findOne').mockResolvedValue(mockProgress); + jest.spyOn(progressRepo, 'save').mockImplementation((progress) => { + progress.currentProgress = 50; + progress.percentageComplete = 50; + return Promise.resolve(progress); + }); + + const result = await service.updateProgress('user-123', 'ach-123', { + currentProgress: 50, + }); + + expect(result.currentProgress).toBe(50); + expect(result.percentageComplete).toBe(50); + }); + + it('should initialize progress if it does not exist', async () => { + jest.spyOn(progressRepo, 'findOne').mockResolvedValue(null); + jest.spyOn(achievementRepo, 'findOne').mockResolvedValue(mockAchievement); + jest.spyOn(progressRepo, 'create').mockReturnValue({ + user: mockUser, + achievement: mockAchievement, + currentProgress: 0, + targetProgress: 100, + percentageComplete: 0, + } as AchievementProgress); + jest.spyOn(progressRepo, 'save').mockResolvedValue({ + id: 'prog-123', + user: mockUser, + achievement: mockAchievement, + currentProgress: 50, + targetProgress: 100, + percentageComplete: 50, + isUnlocked: false, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + lastProgressUpdate: null, + } as AchievementProgress); + + const result = await service.updateProgress('user-123', 'ach-123', { + currentProgress: 50, + }); + + expect(result.currentProgress).toBe(50); + }); + }); + + describe('getUserAchievements', () => { + it('should get all achievements for a user', async () => { + const mockUserAchievements: UserAchievement[] = [ + { + id: 'ua-1', + user: mockUser, + achievement: mockAchievement, + unlockedAt: new Date(), + pointsEarned: 100, + experienceEarned: 50, + notificationSent: true, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + } as UserAchievement, + ]; + + jest.spyOn(userAchievementRepo, 'find').mockResolvedValue(mockUserAchievements); + + const result = await service.getUserAchievements('user-123'); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user-123'); + expect(result[0].achievementId).toBe('ach-123'); + }); + }); + + describe('incrementProgress', () => { + it('should increment progress', async () => { + const mockProgress: AchievementProgress = { + id: 'prog-123', + user: mockUser, + achievement: mockAchievement, + currentProgress: 0, + targetProgress: 10, + percentageComplete: 0, + isUnlocked: false, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + lastProgressUpdate: null, + } as AchievementProgress; + + jest.spyOn(progressRepo, 'findOne').mockResolvedValue(mockProgress); + jest.spyOn(progressRepo, 'save').mockImplementation((progress) => { + progress.currentProgress = 1; + progress.percentageComplete = 10; + return Promise.resolve(progress); + }); + + const result = await service.incrementProgress('user-123', 'ach-123', 1); + + expect(result.currentProgress).toBe(1); + expect(result.percentageComplete).toBe(10); + }); + }); + + describe('getUserAchievementOverview', () => { + it('should get user achievement overview', async () => { + jest.spyOn(achievementRepo, 'find').mockResolvedValue([mockAchievement]); + + const mockUserAchievements: UserAchievement[] = [ + { + id: 'ua-1', + user: mockUser, + achievement: mockAchievement, + unlockedAt: new Date(), + pointsEarned: 100, + experienceEarned: 50, + notificationSent: true, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + } as UserAchievement, + ]; + + jest.spyOn(userAchievementRepo, 'find').mockResolvedValue(mockUserAchievements); + jest.spyOn(userAchievementRepo, 'createQueryBuilder').mockReturnValue({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ count: 10 }), + } as any); + + const result = await service.getUserAchievementOverview('user-123'); + + expect(result.totalAchievements).toBe(1); + expect(result.unlockedAchievements).toBe(1); + expect(result.totalPointsEarned).toBe(100); + expect(result.totalExperienceEarned).toBe(50); + expect(result.progressPercentage).toBe(100); + }); + }); + + describe('hasAchievement', () => { + it('should return true if user has achievement', async () => { + const mockUserAchievement = { id: 'ua-123' } as UserAchievement; + jest.spyOn(userAchievementRepo, 'findOne').mockResolvedValue(mockUserAchievement); + + const result = await service.hasAchievement('user-123', 'ach-123'); + + expect(result).toBe(true); + }); + + it('should return false if user does not have achievement', async () => { + jest.spyOn(userAchievementRepo, 'findOne').mockResolvedValue(null); + + const result = await service.hasAchievement('user-123', 'ach-999'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/achievements/achievements-notifications.service.ts b/src/achievements/achievements-notifications.service.ts new file mode 100644 index 0000000..9e6a914 --- /dev/null +++ b/src/achievements/achievements-notifications.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { Achievement } from './entities/achievement.entity'; + +/** + * Achievements Notifications Service + * Handles sending notifications when achievements are unlocked + */ +@Injectable() +export class AchievementsNotificationsService { + private readonly logger = new Logger(AchievementsNotificationsService.name); + + constructor( + @InjectRepository(UserAchievement) + private userAchievementRepository: Repository, + ) {} + + /** + * Send achievement unlocked notification to a user + * In a real implementation, this would integrate with the NotificationsService + */ + async sendAchievementUnlockedNotification( + userAchievement: UserAchievement, + ): Promise { + try { + const achievement = userAchievement.achievement; + const userId = userAchievement.user.id; + + // Build notification message + const title = `🎉 Achievement Unlocked!`; + const message = `You've unlocked "${achievement.name}"! Earned ${achievement.pointsReward} points and ${achievement.experienceReward} XP.`; + const description = achievement.description; + + // In a real implementation, you would call the NotificationsService here + // Example: + // await this.notificationsService.createNotification({ + // userId, + // type: 'ACHIEVEMENT_UNLOCKED', + // title, + // message, + // description, + // data: { + // achievementId: achievement.id, + // pointsEarned: achievement.pointsReward, + // experienceEarned: achievement.experienceReward, + // unlockedAt: userAchievement.unlockedAt, + // }, + // }); + + this.logger.log( + `Achievement notification would be sent to user ${userId}: ${achievement.name}`, + ); + + // Mark notification as sent + await this.userAchievementRepository.update( + { id: userAchievement.id }, + { notificationSent: true }, + ); + } catch (error) { + this.logger.error( + `Failed to send achievement notification: ${error.message}`, + error.stack, + ); + } + } + + /** + * Send batch notifications for achievements unlocked today + */ + async sendBatchNotifications(): Promise { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const achievements = await this.userAchievementRepository.find({ + where: { + unlockedAt: new Date(), + notificationSent: false, + }, + relations: ['user', 'achievement'], + }); + + let sentCount = 0; + + for (const userAchievement of achievements) { + await this.sendAchievementUnlockedNotification(userAchievement); + sentCount++; + } + + this.logger.log(`Sent ${sentCount} achievement notifications`); + return sentCount; + } catch (error) { + this.logger.error(`Failed to send batch notifications: ${error.message}`, error.stack); + return 0; + } + } + + /** + * Send resend notifications for failed deliveries + */ + async resendFailedNotifications(): Promise { + try { + const achievements = await this.userAchievementRepository.find({ + where: { + notificationSent: false, + }, + relations: ['user', 'achievement'], + take: 100, // Process in batches + }); + + let resendCount = 0; + + for (const userAchievement of achievements) { + await this.sendAchievementUnlockedNotification(userAchievement); + resendCount++; + } + + this.logger.log(`Resent ${resendCount} failed achievement notifications`); + return resendCount; + } catch (error) { + this.logger.error( + `Failed to resend notifications: ${error.message}`, + error.stack, + ); + return 0; + } + } +} diff --git a/src/achievements/achievements.controller.ts b/src/achievements/achievements.controller.ts new file mode 100644 index 0000000..979ecfb --- /dev/null +++ b/src/achievements/achievements.controller.ts @@ -0,0 +1,291 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AchievementsService } from './achievements.service'; +import { + CreateAchievementDto, + UpdateAchievementDto, + AchievementResponseDto, +} from './dto/achievement.dto'; +import { AchievementProgressDto, UpdateAchievementProgressDto } from './dto/achievement-progress.dto'; +import { UserAchievementDto, AchievementUnlockedEventDto } from './dto/user-achievement.dto'; +import { + AchievementStatisticsDto, + AchievementLeaderboardDto, + AchievementOverviewDto, +} from './dto/achievement-statistics.dto'; +import { AchievementType } from './entities/achievement.entity'; + +/** + * Achievements Controller + * Handles all achievement-related API endpoints including: + * - Achievement definitions + * - Progress tracking + * - Achievement unlocking + * - Statistics and leaderboards + */ +@Controller('achievements') +export class AchievementsController { + constructor(private readonly achievementsService: AchievementsService) {} + + // ===================================================== + // Achievement Definition Management + // ===================================================== + + /** + * Create a new achievement + * POST /achievements + */ + @Post() + @HttpCode(HttpStatus.CREATED) + async createAchievement( + @Body() dto: CreateAchievementDto, + ): Promise { + return this.achievementsService.createAchievement(dto); + } + + /** + * Get all achievements + * GET /achievements + */ + @Get() + async getAllAchievements( + @Query('includeHidden') includeHidden?: string, + ): Promise { + return this.achievementsService.getAllAchievements(includeHidden === 'true'); + } + + /** + * Get achievements by type + * GET /achievements/type/:type + */ + @Get('type/:type') + async getAchievementsByType( + @Param('type') type: AchievementType, + ): Promise { + return this.achievementsService.getAchievementsByType(type); + } + + /** + * Get a specific achievement + * GET /achievements/:achievementId + */ + @Get(':achievementId') + async getAchievementById( + @Param('achievementId') achievementId: string, + ): Promise { + return this.achievementsService.getAchievementById(achievementId); + } + + /** + * Update an achievement + * PUT /achievements/:achievementId + */ + @Put(':achievementId') + async updateAchievement( + @Param('achievementId') achievementId: string, + @Body() dto: UpdateAchievementDto, + ): Promise { + return this.achievementsService.updateAchievement(achievementId, dto); + } + + /** + * Deactivate an achievement + * DELETE /achievements/:achievementId + */ + @Delete(':achievementId') + @HttpCode(HttpStatus.NO_CONTENT) + async deactivateAchievement(@Param('achievementId') achievementId: string): Promise { + return this.achievementsService.deactivateAchievement(achievementId); + } + + // ===================================================== + // Progress Tracking + // ===================================================== + + /** + * Initialize progress for a user toward an achievement + * POST /achievements/:achievementId/progress/:userId + */ + @Post(':achievementId/progress/:userId') + @HttpCode(HttpStatus.CREATED) + async initializeProgress( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + ): Promise { + return this.achievementsService.initializeProgress(userId, achievementId); + } + + /** + * Get a user's progress toward an achievement + * GET /achievements/:achievementId/progress/:userId + */ + @Get(':achievementId/progress/:userId') + async getUserProgressForAchievement( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + ): Promise { + return this.achievementsService.getUserProgressForAchievement(userId, achievementId); + } + + /** + * Update a user's progress toward an achievement + * PUT /achievements/:achievementId/progress/:userId + */ + @Put(':achievementId/progress/:userId') + async updateProgress( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + @Body() dto: UpdateAchievementProgressDto, + ): Promise { + return this.achievementsService.updateProgress(userId, achievementId, dto); + } + + /** + * Increment progress for a user toward an achievement + * POST /achievements/:achievementId/progress/:userId/increment + */ + @Post(':achievementId/progress/:userId/increment') + async incrementProgress( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + @Body() body: { incrementBy?: number; metadata?: any }, + ): Promise { + return this.achievementsService.incrementProgress( + userId, + achievementId, + body.incrementBy || 1, + body.metadata, + ); + } + + /** + * Get all progress records for a user + * GET /achievements/progress/:userId + */ + @Get('progress/:userId') + async getUserAllProgress(@Param('userId') userId: string): Promise { + return this.achievementsService.getUserAllProgress(userId); + } + + // ===================================================== + // Achievement Unlocking + // ===================================================== + + /** + * Unlock an achievement for a user + * POST /achievements/:achievementId/unlock/:userId + */ + @Post(':achievementId/unlock/:userId') + @HttpCode(HttpStatus.CREATED) + async unlockAchievement( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + @Body() body?: { metadata?: any }, + ): Promise { + return this.achievementsService.unlockAchievement(userId, achievementId, body?.metadata); + } + + /** + * Get all unlocked achievements for a user + * GET /achievements/user/:userId/unlocked + */ + @Get('user/:userId/unlocked') + async getUserAchievements( + @Param('userId') userId: string, + ): Promise { + return this.achievementsService.getUserAchievements(userId); + } + + /** + * Check if user has an achievement + * GET /achievements/:achievementId/user/:userId/has + */ + @Get(':achievementId/user/:userId/has') + async hasAchievement( + @Param('achievementId') achievementId: string, + @Param('userId') userId: string, + ): Promise<{ hasAchievement: boolean }> { + const has = await this.achievementsService.hasAchievement(userId, achievementId); + return { hasAchievement: has }; + } + + /** + * Get achievement count for a user + * GET /achievements/user/:userId/count + */ + @Get('user/:userId/count') + async getUserAchievementCount(@Param('userId') userId: string): Promise<{ count: number }> { + const count = await this.achievementsService.getUserAchievementCount(userId); + return { count }; + } + + // ===================================================== + // Statistics and Analytics + // ===================================================== + + /** + * Get statistics for an achievement + * GET /achievements/:achievementId/statistics + */ + @Get(':achievementId/statistics') + async getAchievementStatistics( + @Param('achievementId') achievementId: string, + ): Promise { + return this.achievementsService.getAchievementStatistics(achievementId); + } + + /** + * Get achievement overview for a user + * GET /achievements/user/:userId/overview + */ + @Get('user/:userId/overview') + async getUserAchievementOverview( + @Param('userId') userId: string, + ): Promise { + return this.achievementsService.getUserAchievementOverview(userId); + } + + /** + * Get achievements leaderboard + * GET /achievements/leaderboard + */ + @Get('leaderboard') + async getAchievementsLeaderboard( + @Query('limit') limit: string = '10', + ): Promise { + return this.achievementsService.getAchievementsLeaderboard(parseInt(limit, 10)); + } + + /** + * Get all statistics + * GET /achievements/statistics/all + */ + @Get('statistics/all') + async getAllAchievementsStatistics(): Promise { + return this.achievementsService.getAllAchievementsStatistics(); + } + + /** + * Batch unlock achievements + * POST /achievements/batch-unlock/:userId + */ + @Post('batch-unlock/:userId') + @HttpCode(HttpStatus.CREATED) + async batchUnlockAchievements( + @Param('userId') userId: string, + @Body() body: { achievementIds: string[] }, + ): Promise { + return this.achievementsService.batchUnlockAchievements(userId, body.achievementIds); + } +} diff --git a/src/achievements/achievements.integration.example.ts b/src/achievements/achievements.integration.example.ts new file mode 100644 index 0000000..95513cf --- /dev/null +++ b/src/achievements/achievements.integration.example.ts @@ -0,0 +1,283 @@ +/** + * Integration Example: How to use the Achievements System + * This file demonstrates how to integrate the achievement system with other modules + */ + +import { Injectable } from '@nestjs/common'; +import { AchievementsService } from './achievements.service'; +import { AchievementsNotificationsService } from './achievements-notifications.service'; +import { AchievementType, AchievementDifficulty } from './entities/achievement.entity'; + +@Injectable() +export class AchievementsIntegrationExample { + constructor( + private achievementsService: AchievementsService, + private notificationsService: AchievementsNotificationsService, + ) {} + + /** + * EXAMPLE 1: When a user completes a lesson + * Call this from the lessons service + */ + async onLessonCompleted(userId: string, lessonId: string): Promise { + // 1. Get or initialize progress for lesson-based achievements + const completionAchievement = 'lessons-completed-achievement-id'; // In real code, fetch by criteria + + // 2. Increment progress + await this.achievementsService.incrementProgress(userId, completionAchievement, 1, { + lessonId, + completedAt: new Date(), + }); + + // 3. Check if user earned any new achievements + const userAchievements = await this.achievementsService.getUserAchievements(userId); + console.log(`User has ${userAchievements.length} achievements`); + } + + /** + * EXAMPLE 2: When a user completes a course + * Call this from the courses service + */ + async onCourseCompleted(userId: string, courseId: string): Promise { + const courseCompletionAchievement = 'courses-completed-achievement-id'; + + // Increment progress + const progress = await this.achievementsService.incrementProgress( + userId, + courseCompletionAchievement, + 1, + { + courseId, + completedAt: new Date(), + }, + ); + + console.log( + `User progress: ${progress.currentProgress}/${progress.targetProgress} (${progress.percentageComplete}%)`, + ); + } + + /** + * EXAMPLE 3: Daily streak tracking + * Call this daily (via a cron job or scheduler) + */ + async updateDailyStreaks(userId: string): Promise { + const streakAchievementId = 'week-warrior-achievement-id'; + + // Get current streak + const progress = await this.achievementsService.getUserProgressForAchievement( + userId, + streakAchievementId, + ); + + // Check if user has completed a lesson today + const completedTodayInProgress = true; // Check via lesson service + + if (completedTodayInProgress) { + // Increment streak + const updated = await this.achievementsService.incrementProgress( + userId, + streakAchievementId, + 1, + { + date: new Date().toISOString().split('T')[0], + }, + ); + + console.log(`Streak updated: ${updated.currentProgress} days`); + } + } + + /** + * EXAMPLE 4: Manual achievement unlock + * Call this for special cases or admin actions + */ + async awardAchievementManually(userId: string, achievementName: string): Promise { + // 1. Find achievement by name + const achievements = await this.achievementsService.getAllAchievements(); + const achievement = achievements.find((a) => a.name === achievementName); + + if (!achievement) { + console.error(`Achievement not found: ${achievementName}`); + return; + } + + // 2. Unlock achievement + const unlockedEvent = await this.achievementsService.unlockAchievement( + userId, + achievement.id, + { + reason: 'manual_award', + adminId: 'admin-user-id', + }, + ); + + console.log(`Achievement unlocked: ${achievement.name}`); + console.log(`Points earned: ${unlockedEvent.pointsEarned}`); + console.log(`Experience earned: ${unlockedEvent.experienceEarned}`); + } + + /** + * EXAMPLE 5: Getting user's achievement progress + * Use this for displaying user profile/dashboard + */ + async getUserAchievementDashboard(userId: string): Promise { + // Get overview + const overview = await this.achievementsService.getUserAchievementOverview(userId); + + // Get all achievements with progress + const allAchievements = await this.achievementsService.getAllAchievements(); + const userProgress = await this.achievementsService.getUserAllProgress(userId); + const userUnlocked = await this.achievementsService.getUserAchievements(userId); + + return { + summary: overview, + progress: userProgress.map((p) => ({ + achievement: p.achievement.name, + progress: `${p.currentProgress}/${p.targetProgress}`, + percentage: p.percentageComplete, + })), + unlocked: userUnlocked.map((a) => ({ + achievement: a.achievement.name, + unlockedAt: a.unlockedAt, + pointsEarned: a.pointsEarned, + })), + }; + } + + /** + * EXAMPLE 6: Getting system statistics + * Use this for admin dashboards + */ + async getSystemAchievementStats(): Promise { + const achievements = await this.achievementsService.getAllAchievements(); + const leaderboard = await this.achievementsService.getAchievementsLeaderboard(10); + + const statsByAchievement = await Promise.all( + achievements.map(async (achievement) => { + const stats = await this.achievementsService.getAchievementStatistics(achievement.id); + return { + name: achievement.name, + totalUnlocked: stats.totalUnlocked, + unlockedPercentage: stats.unlockedPercentage, + activeTrackers: stats.activeTrackers, + engagementTrend: stats.engagementTrend, + }; + }), + ); + + return { + topAchievements: statsByAchievement.sort((a, b) => b.totalUnlocked - a.totalUnlocked), + topUsers: leaderboard, + totalAchievements: achievements.length, + }; + } + + /** + * EXAMPLE 7: Creating a new achievement + * Call this during setup or admin operations + */ + async createNewAchievement(): Promise { + const newAchievement = await this.achievementsService.createAchievement({ + name: 'Code Reviewer', + description: 'Review 10 peer submissions', + longDescription: 'Become an expert code reviewer by providing feedback on 10 submissions', + iconUrl: 'https://example.com/icons/code-reviewer.png', + type: AchievementType.ENGAGEMENT, + difficulty: AchievementDifficulty.HARD, + pointsReward: 400, + experienceReward: 200, + criteria: { + type: 'PEER_REVIEWS', + target: 10, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 10, + }, + }); + + console.log(`Achievement created: ${newAchievement.id}`); + } + + /** + * EXAMPLE 8: Checking user achievement status + * Use this for authorization or feature gates + */ + async checkUserAchievementForFeatureGate( + userId: string, + achievementName: string, + ): Promise { + const achievements = await this.achievementsService.getAllAchievements(); + const achievement = achievements.find((a) => a.name === achievementName); + + if (!achievement) { + return false; + } + + return this.achievementsService.hasAchievement(userId, achievement.id); + } + + /** + * EXAMPLE 9: Bulk operations for testing/migrations + * Use this for seeding or data operations + */ + async bulkUnlockAchievementsForUser(userId: string, count: number): Promise { + const achievements = await this.achievementsService.getAllAchievements(); + const achievementsToUnlock = achievements.slice(0, count).map((a) => a.id); + + await this.achievementsService.batchUnlockAchievements(userId, achievementsToUnlock); + + console.log(`Unlocked ${achievementsToUnlock.length} achievements for user ${userId}`); + } + + /** + * EXAMPLE 10: Integration with notification service + * Send notifications when achievements are unlocked + */ + async onAchievementUnlocked(userId: string, achievementId: string): Promise { + const achievement = await this.achievementsService.getAchievementById(achievementId); + + // This would integrate with the NotificationsService + const notificationData = { + userId, + type: 'ACHIEVEMENT_UNLOCKED', + title: `🎉 Achievement Unlocked!`, + message: `You've unlocked "${achievement.name}"!`, + data: { + achievementId, + pointsEarned: achievement.pointsReward, + experienceEarned: achievement.experienceReward, + }, + }; + + // await this.notificationsService.sendNotification(notificationData); + console.log('Notification would be sent:', notificationData); + } +} + +/** + * USAGE IN OTHER MODULES: + * + * 1. In CoursesService (when course is completed): + * constructor(private achievements: AchievementsService) {} + * async completeCourse(userId: string, courseId: string) { + * // ... course completion logic + * await this.achievements.incrementProgress(userId, 'course-completion-id', 1); + * } + * + * 2. In a Scheduler (for daily streaks): + * @Cron('0 23 * * *') // Daily at 11 PM + * async updateStreaks() { + * const users = await this.usersService.getAllActiveUsers(); + * for (const user of users) { + * await this.achievements.updateDailyStreaks(user.id); + * } + * } + * + * 3. In a Guard (for feature access): + * canActivate(context: ExecutionContext) { + * const hasAch = await this.achievements.hasAchievement(userId, achId); + * return hasAch; + * } + */ diff --git a/src/achievements/achievements.module.ts b/src/achievements/achievements.module.ts new file mode 100644 index 0000000..efc5651 --- /dev/null +++ b/src/achievements/achievements.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { Achievement } from './entities/achievement.entity'; +import { AchievementProgress } from './entities/achievement-progress.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { AchievementStatistics } from './entities/achievement-statistics.entity'; +import { AchievementsService } from './achievements.service'; +import { AchievementsController } from './achievements.controller'; +import { AchievementsNotificationsService } from './achievements-notifications.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Achievement, + AchievementProgress, + UserAchievement, + AchievementStatistics, + ]), + ScheduleModule.forRoot(), + ], + controllers: [AchievementsController], + providers: [AchievementsService, AchievementsNotificationsService], + exports: [AchievementsService, AchievementsNotificationsService], +}) +export class AchievementsModule {} diff --git a/src/achievements/achievements.seed.ts b/src/achievements/achievements.seed.ts new file mode 100644 index 0000000..2e0e373 --- /dev/null +++ b/src/achievements/achievements.seed.ts @@ -0,0 +1,320 @@ +import { AchievementType, AchievementDifficulty } from './entities/achievement.entity'; + +/** + * Seed data for default achievements + * This can be used to populate the database with standard achievements + */ +export const DEFAULT_ACHIEVEMENTS = [ + // Milestone achievements + { + name: 'First Lesson', + description: 'Complete your first lesson', + longDescription: 'Begin your learning journey by completing your first lesson', + iconUrl: 'https://example.com/icons/first-lesson.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 50, + experienceReward: 25, + criteria: { + type: 'LESSONS_COMPLETED', + target: 1, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 1, + }, + }, + { + name: 'Lesson Enthusiast', + description: 'Complete 10 lessons', + longDescription: 'Demonstrates consistent engagement with learning', + iconUrl: 'https://example.com/icons/lesson-enthusiast.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.MEDIUM, + pointsReward: 150, + experienceReward: 100, + criteria: { + type: 'LESSONS_COMPLETED', + target: 10, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 10, + }, + }, + { + name: 'Lesson Master', + description: 'Complete 50 lessons', + longDescription: 'You are a dedicated learner', + iconUrl: 'https://example.com/icons/lesson-master.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.HARD, + pointsReward: 500, + experienceReward: 250, + criteria: { + type: 'LESSONS_COMPLETED', + target: 50, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 50, + }, + }, + { + name: 'First Course', + description: 'Complete your first course', + longDescription: 'Celebrate completing your first full course', + iconUrl: 'https://example.com/icons/first-course.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { + type: 'COURSES_COMPLETED', + target: 1, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 1, + }, + }, + { + name: 'Course Champion', + description: 'Complete 5 courses', + longDescription: 'You have completed multiple courses', + iconUrl: 'https://example.com/icons/course-champion.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.HARD, + pointsReward: 400, + experienceReward: 200, + criteria: { + type: 'COURSES_COMPLETED', + target: 5, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 5, + }, + }, + + // Streak achievements + { + name: 'Week Warrior', + description: 'Maintain a 7-day streak', + longDescription: 'Complete at least one lesson every day for a week', + iconUrl: 'https://example.com/icons/week-warrior.png', + type: AchievementType.STREAKS, + difficulty: AchievementDifficulty.MEDIUM, + pointsReward: 200, + experienceReward: 100, + criteria: { + type: 'DAYS_STREAK', + target: 7, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 7, + }, + }, + { + name: 'Month Marathon', + description: 'Maintain a 30-day streak', + longDescription: 'Complete at least one lesson every day for a month', + iconUrl: 'https://example.com/icons/month-marathon.png', + type: AchievementType.STREAKS, + difficulty: AchievementDifficulty.HARD, + pointsReward: 600, + experienceReward: 300, + criteria: { + type: 'DAYS_STREAK', + target: 30, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 30, + }, + }, + { + name: 'Unstoppable', + description: 'Maintain a 100-day streak', + longDescription: 'An incredible display of dedication and consistency', + iconUrl: 'https://example.com/icons/unstoppable.png', + type: AchievementType.STREAKS, + difficulty: AchievementDifficulty.LEGENDARY, + pointsReward: 2000, + experienceReward: 1000, + criteria: { + type: 'DAYS_STREAK', + target: 100, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 100, + }, + }, + + // Skill-based achievements + { + name: 'Quick Learner', + description: 'Complete a lesson in under 5 minutes', + longDescription: 'Demonstrates rapid comprehension', + iconUrl: 'https://example.com/icons/quick-learner.png', + type: AchievementType.SKILL_BASED, + difficulty: AchievementDifficulty.MEDIUM, + pointsReward: 150, + experienceReward: 75, + criteria: { + type: 'LESSON_TIME', + target: 5, + unit: 'minutes', + }, + progressConfig: { + trackingType: 'binary', + maxProgress: 1, + }, + }, + { + name: 'Perfect Score', + description: 'Achieve 100% on a lesson assessment', + longDescription: 'Demonstrates mastery of the material', + iconUrl: 'https://example.com/icons/perfect-score.png', + type: AchievementType.SKILL_BASED, + difficulty: AchievementDifficulty.HARD, + pointsReward: 300, + experienceReward: 150, + criteria: { + type: 'ASSESSMENT_SCORE', + target: 100, + }, + progressConfig: { + trackingType: 'binary', + maxProgress: 1, + }, + }, + + // Engagement achievements + { + name: 'Early Bird', + description: 'Complete a lesson before 9 AM', + longDescription: 'Start your day with learning', + iconUrl: 'https://example.com/icons/early-bird.png', + type: AchievementType.ENGAGEMENT, + difficulty: AchievementDifficulty.EASY, + pointsReward: 50, + experienceReward: 25, + criteria: { + type: 'COMPLETION_TIME', + target: '09:00', + }, + progressConfig: { + trackingType: 'binary', + maxProgress: 1, + }, + }, + { + name: 'Active Participant', + description: 'Post 5 discussion comments', + longDescription: 'Engage with the learning community', + iconUrl: 'https://example.com/icons/active-participant.png', + type: AchievementType.ENGAGEMENT, + difficulty: AchievementDifficulty.MEDIUM, + pointsReward: 200, + experienceReward: 100, + criteria: { + type: 'DISCUSSION_POSTS', + target: 5, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 5, + }, + }, + { + name: 'Community Helper', + description: 'Receive 10 helpful votes on forum posts', + longDescription: 'Help others in the community', + iconUrl: 'https://example.com/icons/community-helper.png', + type: AchievementType.ENGAGEMENT, + difficulty: AchievementDifficulty.HARD, + pointsReward: 400, + experienceReward: 200, + criteria: { + type: 'HELPFUL_VOTES', + target: 10, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 10, + }, + }, + + // Contribution achievements + { + name: 'Educator', + description: 'Create your first course', + longDescription: 'Share your knowledge with others', + iconUrl: 'https://example.com/icons/educator.png', + type: AchievementType.CONTRIBUTION, + difficulty: AchievementDifficulty.HARD, + pointsReward: 500, + experienceReward: 250, + criteria: { + type: 'COURSES_CREATED', + target: 1, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 1, + }, + }, + { + name: 'Course Creator', + description: 'Create 5 courses', + longDescription: 'Establish yourself as a course creator', + iconUrl: 'https://example.com/icons/course-creator.png', + type: AchievementType.CONTRIBUTION, + difficulty: AchievementDifficulty.LEGENDARY, + pointsReward: 1500, + experienceReward: 750, + criteria: { + type: 'COURSES_CREATED', + target: 5, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 5, + }, + }, + { + name: 'Lesson Author', + description: 'Create 10 lessons', + longDescription: 'Create comprehensive learning content', + iconUrl: 'https://example.com/icons/lesson-author.png', + type: AchievementType.CONTRIBUTION, + difficulty: AchievementDifficulty.HARD, + pointsReward: 600, + experienceReward: 300, + criteria: { + type: 'LESSONS_CREATED', + target: 10, + }, + progressConfig: { + trackingType: 'incremental', + maxProgress: 10, + }, + }, +]; + +/** + * Helper function to seed achievements into the database + */ +export async function seedAchievements(achievementsService: any): Promise { + try { + for (const achievementData of DEFAULT_ACHIEVEMENTS) { + await achievementsService.createAchievement(achievementData); + } + console.log(`✅ Seeded ${DEFAULT_ACHIEVEMENTS.length} achievements`); + } catch (error) { + console.error('❌ Error seeding achievements:', error); + } +} diff --git a/src/achievements/achievements.service.ts b/src/achievements/achievements.service.ts new file mode 100644 index 0000000..4963862 --- /dev/null +++ b/src/achievements/achievements.service.ts @@ -0,0 +1,672 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan, LessThan } from 'typeorm'; +import { Achievement, AchievementType, AchievementDifficulty } from './entities/achievement.entity'; +import { AchievementProgress } from './entities/achievement-progress.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { AchievementStatistics } from './entities/achievement-statistics.entity'; +import { User } from '../users/entities/user.entity'; +import { + CreateAchievementDto, + UpdateAchievementDto, + AchievementResponseDto, +} from './dto/achievement.dto'; +import { AchievementProgressDto, UpdateAchievementProgressDto } from './dto/achievement-progress.dto'; +import { UserAchievementDto, AchievementUnlockedEventDto } from './dto/user-achievement.dto'; +import { + AchievementStatisticsDto, + AchievementLeaderboardDto, + AchievementOverviewDto, +} from './dto/achievement-statistics.dto'; + +@Injectable() +export class AchievementsService { + private readonly logger = new Logger(AchievementsService.name); + + constructor( + @InjectRepository(Achievement) + private achievementRepository: Repository, + @InjectRepository(AchievementProgress) + private progressRepository: Repository, + @InjectRepository(UserAchievement) + private userAchievementRepository: Repository, + @InjectRepository(AchievementStatistics) + private statisticsRepository: Repository, + ) {} + + // ===================================================== + // Achievement Definition Management + // ===================================================== + + /** + * Create a new achievement definition + */ + async createAchievement(dto: CreateAchievementDto): Promise { + const achievement = this.achievementRepository.create({ + ...dto, + isActive: true, + unlockedBy: 0, + }); + + const saved = await this.achievementRepository.save(achievement); + this.logger.log(`Achievement created: ${saved.id} - ${saved.name}`); + + return this.toAchievementResponseDto(saved); + } + + /** + * Get all achievements + */ + async getAllAchievements( + includeHidden: boolean = false, + ): Promise { + const query = this.achievementRepository.createQueryBuilder('achievement'); + + if (!includeHidden) { + query.andWhere('achievement.isHidden = :isHidden', { isHidden: false }); + } + + const achievements = await query + .andWhere('achievement.isActive = :isActive', { isActive: true }) + .orderBy('achievement.difficulty', 'ASC') + .addOrderBy('achievement.createdAt', 'ASC') + .getMany(); + + return achievements.map((a) => this.toAchievementResponseDto(a)); + } + + /** + * Get achievement by ID + */ + async getAchievementById(achievementId: string): Promise { + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException(`Achievement not found: ${achievementId}`); + } + + return this.toAchievementResponseDto(achievement); + } + + /** + * Get achievements by type + */ + async getAchievementsByType(type: AchievementType): Promise { + const achievements = await this.achievementRepository.find({ + where: { type, isActive: true, isHidden: false }, + order: { difficulty: 'ASC' }, + }); + + return achievements.map((a) => this.toAchievementResponseDto(a)); + } + + /** + * Update achievement definition + */ + async updateAchievement( + achievementId: string, + dto: UpdateAchievementDto, + ): Promise { + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException(`Achievement not found: ${achievementId}`); + } + + Object.assign(achievement, dto); + const saved = await this.achievementRepository.save(achievement); + + this.logger.log(`Achievement updated: ${achievementId}`); + return this.toAchievementResponseDto(saved); + } + + /** + * Delete achievement (soft delete via isActive flag) + */ + async deactivateAchievement(achievementId: string): Promise { + await this.achievementRepository.update( + { id: achievementId }, + { isActive: false }, + ); + + this.logger.log(`Achievement deactivated: ${achievementId}`); + } + + // ===================================================== + // Progress Tracking + // ===================================================== + + /** + * Initialize progress tracking for a user toward an achievement + */ + async initializeProgress( + userId: string, + achievementId: string, + ): Promise { + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException(`Achievement not found: ${achievementId}`); + } + + // Check if progress already exists + let progress = await this.progressRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + relations: ['achievement'], + }); + + if (progress) { + return this.toAchievementProgressDto(progress); + } + + // Initialize new progress + const targetProgress = achievement.progressConfig?.maxProgress || 1; + + progress = this.progressRepository.create({ + user: { id: userId } as User, + achievement, + currentProgress: 0, + targetProgress, + percentageComplete: 0, + isUnlocked: false, + }); + + const saved = await this.progressRepository.save(progress); + this.logger.log( + `Progress initialized for user ${userId} toward achievement ${achievementId}`, + ); + + return this.toAchievementProgressDto(saved); + } + + /** + * Update achievement progress for a user + */ + async updateProgress( + userId: string, + achievementId: string, + dto: UpdateAchievementProgressDto, + ): Promise { + let progress = await this.progressRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + relations: ['achievement'], + }); + + if (!progress) { + // Initialize if doesn't exist + progress = await this.initializeProgress(userId, achievementId); + } + + progress.currentProgress = Math.min(dto.currentProgress, progress.targetProgress); + progress.percentageComplete = Math.round( + (progress.currentProgress / progress.targetProgress) * 100, + ); + progress.lastProgressUpdate = new Date(); + + if (dto.metadata) { + progress.metadata = { ...progress.metadata, ...dto.metadata }; + } + + const saved = await this.progressRepository.save(progress); + + this.logger.log( + `Progress updated for user ${userId}: achievement ${achievementId} - ${progress.percentageComplete}%`, + ); + + // Check if achievement should be unlocked + if ( + !progress.isUnlocked && + progress.currentProgress >= progress.targetProgress + ) { + await this.unlockAchievement(userId, achievementId); + } + + return this.toAchievementProgressDto(saved); + } + + /** + * Get progress for a specific user toward an achievement + */ + async getUserProgressForAchievement( + userId: string, + achievementId: string, + ): Promise { + const progress = await this.progressRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + relations: ['achievement'], + }); + + if (!progress) { + throw new NotFoundException( + `Progress not found for user ${userId} and achievement ${achievementId}`, + ); + } + + return this.toAchievementProgressDto(progress); + } + + /** + * Get all progress records for a user + */ + async getUserAllProgress(userId: string): Promise { + const progresses = await this.progressRepository.find({ + where: { user: { id: userId } }, + relations: ['achievement'], + order: { createdAt: 'DESC' }, + }); + + return progresses.map((p) => this.toAchievementProgressDto(p)); + } + + /** + * Increment progress by a specified amount + */ + async incrementProgress( + userId: string, + achievementId: string, + incrementBy: number = 1, + metadata?: any, + ): Promise { + let progress = await this.progressRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + relations: ['achievement'], + }); + + if (!progress) { + progress = await this.initializeProgress(userId, achievementId); + } + + const newProgress = Math.min( + progress.currentProgress + incrementBy, + progress.targetProgress, + ); + + return this.updateProgress(userId, achievementId, { + currentProgress: newProgress, + metadata, + }); + } + + // ===================================================== + // Achievement Unlocking + // ===================================================== + + /** + * Unlock an achievement for a user + */ + async unlockAchievement( + userId: string, + achievementId: string, + metadata?: any, + ): Promise { + // Check if already unlocked + const existing = await this.userAchievementRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + relations: ['achievement'], + }); + + if (existing) { + return this.toAchievementUnlockedEventDto(existing); + } + + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException(`Achievement not found: ${achievementId}`); + } + + const userAchievement = this.userAchievementRepository.create({ + user: { id: userId } as User, + achievement, + unlockedAt: new Date(), + unlockedMetadata: metadata, + pointsEarned: achievement.pointsReward, + experienceEarned: achievement.experienceReward, + notificationSent: false, + }); + + const saved = await this.userAchievementRepository.save(userAchievement); + + // Update progress record + await this.progressRepository.update( + { + user: { id: userId }, + achievement: { id: achievementId }, + }, + { isUnlocked: true }, + ); + + // Increment unlocked count + await this.achievementRepository.increment( + { id: achievementId }, + 'unlockedBy', + 1, + ); + + this.logger.log( + `Achievement unlocked for user ${userId}: ${achievementId} - earned ${achievement.pointsReward} points, ${achievement.experienceReward} XP`, + ); + + return this.toAchievementUnlockedEventDto(saved); + } + + /** + * Get all unlocked achievements for a user + */ + async getUserAchievements(userId: string): Promise { + const achievements = await this.userAchievementRepository.find({ + where: { user: { id: userId } }, + relations: ['achievement'], + order: { unlockedAt: 'DESC' }, + }); + + return achievements.map((a) => this.toUserAchievementDto(a)); + } + + /** + * Check if user has unlocked an achievement + */ + async hasAchievement(userId: string, achievementId: string): Promise { + const achievement = await this.userAchievementRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + }); + + return !!achievement; + } + + /** + * Get achievement unlock count + */ + async getUserAchievementCount(userId: string): Promise { + return this.userAchievementRepository.count({ + where: { user: { id: userId } }, + }); + } + + // ===================================================== + // Statistics and Analytics + // ===================================================== + + /** + * Get statistics for an achievement + */ + async getAchievementStatistics(achievementId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const achievement = await this.achievementRepository.findOne({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new NotFoundException(`Achievement not found: ${achievementId}`); + } + + const totalUnlocked = await this.userAchievementRepository.count({ + where: { achievement: { id: achievementId } }, + }); + + const unlockedToday = await this.userAchievementRepository.count({ + where: { + achievement: { id: achievementId }, + unlockedAt: MoreThan(today), + }, + }); + + const activeTrackers = await this.progressRepository.count({ + where: { + achievement: { id: achievementId }, + isUnlocked: false, + }, + }); + + // Get average progress + const progresses = await this.progressRepository.find({ + where: { + achievement: { id: achievementId }, + isUnlocked: false, + }, + }); + + const averageProgress = + progresses.length > 0 + ? progresses.reduce((sum, p) => sum + p.percentageComplete, 0) / + progresses.length + : 0; + + // Estimate total users (for percentage calculation) + const totalUsers = await this.progressRepository + .createQueryBuilder('progress') + .select('COUNT(DISTINCT progress.userId)', 'count') + .getRawOne(); + + const unlockedPercentage = + (totalUsers?.count > 0 ? (totalUnlocked / totalUsers.count) * 100 : 0) || 0; + + const stats = this.statisticsRepository.create({ + achievementId, + date: today, + totalUnlocked, + unlockedToday, + unlockedPercentage: Math.round(unlockedPercentage * 100) / 100, + activeTrackers, + averageProgress: Math.round(averageProgress * 100) / 100, + }); + + const saved = await this.statisticsRepository.save(stats); + return this.toAchievementStatisticsDto(saved); + } + + /** + * Get user achievement overview + */ + async getUserAchievementOverview(userId: string): Promise { + const allAchievements = await this.achievementRepository.find({ + where: { isActive: true }, + }); + + const userAchievements = await this.userAchievementRepository.find({ + where: { user: { id: userId } }, + }); + + const totalPoints = userAchievements.reduce((sum, a) => sum + a.pointsEarned, 0); + const totalExperience = userAchievements.reduce( + (sum, a) => sum + a.experienceEarned, + 0, + ); + + // Get rank (users with more achievements ranked higher) + const rank = await this.userAchievementRepository + .createQueryBuilder('ua') + .select('COUNT(DISTINCT ua.userId)', 'count') + .where('(SELECT COUNT(*) FROM user_achievements WHERE "userId" = ua."userId") > :userCount', { + userCount: userAchievements.length, + }) + .getRawOne(); + + const progressPercentage = + allAchievements.length > 0 + ? Math.round((userAchievements.length / allAchievements.length) * 100) + : 0; + + return { + totalAchievements: allAchievements.length, + unlockedAchievements: userAchievements.length, + progressPercentage, + totalPointsEarned: totalPoints, + totalExperienceEarned: totalExperience, + userRank: (rank?.count || 0) + 1, + }; + } + + /** + * Get achievements leaderboard + */ + async getAchievementsLeaderboard(limit: number = 10): Promise { + const results = await this.userAchievementRepository + .createQueryBuilder('ua') + .select('ua.userId', 'userId') + .addSelect('COUNT(ua.id)', 'totalAchievements') + .addSelect('SUM(ua.pointsEarned)', 'totalPoints') + .addSelect('SUM(ua.experienceEarned)', 'totalExperience') + .groupBy('ua.userId') + .orderBy('totalAchievements', 'DESC') + .addOrderBy('totalPoints', 'DESC') + .limit(limit) + .getRawMany(); + + // Enhance with user info (could join with users table) + return results.map((r, index) => ({ + userId: r.userId, + username: r.userId, // Would need join to get actual username + totalAchievements: parseInt(r.totalAchievements, 10), + totalPoints: parseInt(r.totalPoints, 10) || 0, + totalExperience: parseInt(r.totalExperience, 10) || 0, + rank: index + 1, + })); + } + + /** + * Get statistics for all achievements + */ + async getAllAchievementsStatistics(): Promise { + const stats = await this.statisticsRepository.find({ + order: { date: 'DESC' }, + }); + + return stats.map((s) => this.toAchievementStatisticsDto(s)); + } + + /** + * Batch unlock achievements (for seeding or migrations) + */ + async batchUnlockAchievements( + userId: string, + achievementIds: string[], + ): Promise { + const results: AchievementUnlockedEventDto[] = []; + + for (const achievementId of achievementIds) { + const result = await this.unlockAchievement(userId, achievementId); + results.push(result); + } + + return results; + } + + // ===================================================== + // Helper Methods + // ===================================================== + + private toAchievementResponseDto(achievement: Achievement): AchievementResponseDto { + return { + id: achievement.id, + name: achievement.name, + description: achievement.description, + longDescription: achievement.longDescription, + iconUrl: achievement.iconUrl, + type: achievement.type, + difficulty: achievement.difficulty, + pointsReward: achievement.pointsReward, + experienceReward: achievement.experienceReward, + criteria: achievement.criteria, + progressConfig: achievement.progressConfig, + isActive: achievement.isActive, + isHidden: achievement.isHidden, + unlockedBy: achievement.unlockedBy, + createdAt: achievement.createdAt, + updatedAt: achievement.updatedAt, + }; + } + + private toAchievementProgressDto(progress: AchievementProgress): AchievementProgressDto { + return { + id: progress.id, + userId: progress.user.id, + achievementId: progress.achievement.id, + achievement: this.toAchievementResponseDto(progress.achievement), + currentProgress: progress.currentProgress, + targetProgress: progress.targetProgress, + percentageComplete: progress.percentageComplete, + isUnlocked: progress.isUnlocked, + lastProgressUpdate: progress.lastProgressUpdate, + metadata: progress.metadata, + createdAt: progress.createdAt, + updatedAt: progress.updatedAt, + }; + } + + private toUserAchievementDto(userAchievement: UserAchievement): UserAchievementDto { + return { + id: userAchievement.id, + userId: userAchievement.user.id, + achievementId: userAchievement.achievement.id, + achievement: this.toAchievementResponseDto(userAchievement.achievement), + unlockedAt: userAchievement.unlockedAt, + unlockedMetadata: userAchievement.unlockedMetadata, + pointsEarned: userAchievement.pointsEarned, + experienceEarned: userAchievement.experienceEarned, + notificationSent: userAchievement.notificationSent, + isHidden: userAchievement.isHidden, + createdAt: userAchievement.createdAt, + updatedAt: userAchievement.updatedAt, + }; + } + + private toAchievementUnlockedEventDto( + userAchievement: UserAchievement, + ): AchievementUnlockedEventDto { + return { + userId: userAchievement.user.id, + achievementId: userAchievement.achievement.id, + achievement: this.toAchievementResponseDto(userAchievement.achievement), + pointsEarned: userAchievement.pointsEarned, + experienceEarned: userAchievement.experienceEarned, + unlockedAt: userAchievement.unlockedAt, + }; + } + + private toAchievementStatisticsDto(stats: AchievementStatistics): AchievementStatisticsDto { + return { + id: stats.id, + achievementId: stats.achievementId, + date: stats.date, + totalUnlocked: stats.totalUnlocked, + unlockedToday: stats.unlockedToday, + unlockedPercentage: Number(stats.unlockedPercentage), + averageTimeToUnlock: stats.averageTimeToUnlock, + activeTrackers: stats.activeTrackers, + averageProgress: Number(stats.averageProgress), + engagementTrend: stats.engagementTrend, + metadata: stats.metadata, + createdAt: stats.createdAt, + updatedAt: stats.updatedAt, + }; + } +} diff --git a/src/achievements/achievements.types.ts b/src/achievements/achievements.types.ts new file mode 100644 index 0000000..3711841 --- /dev/null +++ b/src/achievements/achievements.types.ts @@ -0,0 +1,185 @@ +/** + * Achievement Types - Define what kind of achievement it is + */ +export enum AchievementTypeEnum { + MILESTONE = 'milestone', // Reached a target (e.g., 10 lessons) + CHALLENGE = 'challenge', // Complete a specific challenge + STREAKS = 'streaks', // Maintain consistency (e.g., 7-day streak) + SKILL_BASED = 'skill_based', // Demonstrate skill mastery + ENGAGEMENT = 'engagement', // Community participation + CONTRIBUTION = 'contribution', // Content creation +} + +/** + * Achievement Difficulty - Indicates challenge level + */ +export enum AchievementDifficultyEnum { + EASY = 'easy', // 5-10 minutes to unlock + MEDIUM = 'medium', // 30 minutes to 2 hours + HARD = 'hard', // Days to weeks + LEGENDARY = 'legendary', // Months of commitment +} + +/** + * Progress Configuration Type + */ +export interface ProgressConfig { + trackingType: 'incremental' | 'binary'; // Incremental = count-based, Binary = yes/no + maxProgress: number; // Target value to unlock +} + +/** + * Achievement Criteria - Define unlock conditions + */ +export interface AchievementCriteria { + type: string; // e.g., 'LESSONS_COMPLETED', 'COURSES_COMPLETED', 'DAYS_STREAK' + target: number; // Target value to reach + [key: string]: any; // Additional criteria-specific fields +} + +/** + * Achievement Metadata + */ +export interface AchievementMetadata { + category?: string; + tags?: string[]; + prerequisites?: string[]; // Achievement IDs required before this one + seasonalStart?: Date; + seasonalEnd?: Date; + maxUnlocks?: number; // Limit how many can unlock this + [key: string]: any; +} + +/** + * Progress Update Context + */ +export interface ProgressContext { + [key: string]: any; // Context data (e.g., lessonId, courseId, etc.) +} + +/** + * Unlock Context + */ +export interface UnlockContext { + reason?: string; // e.g., 'auto_unlock', 'manual_award', 'challenge_complete' + triggeredBy?: string; // User/system ID that triggered unlock + [key: string]: any; // Additional context +} + +/** + * Statistics Timeframe + */ +export enum StatisticsTimeframe { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + YEARLY = 'yearly', + ALL_TIME = 'all_time', +} + +/** + * Engagement Trend + */ +export enum EngagementTrend { + POSITIVE = 'positive', // Increasing unlock rate + NEGATIVE = 'negative', // Decreasing unlock rate + STABLE = 'stable', // Stable unlock rate +} + +/** + * Achievement Status (user-specific) + */ +export enum UserAchievementStatus { + LOCKED = 'locked', // Not started or in progress + IN_PROGRESS = 'in_progress', // Making progress + NEARLY_UNLOCKED = 'nearly_unlocked', // >80% progress + UNLOCKED = 'unlocked', // Achievement earned + EXPIRED = 'expired', // Time-limited achievement expired +} + +/** + * Criteria Type Constants + */ +export const CRITERIA_TYPES = { + LESSONS_COMPLETED: 'LESSONS_COMPLETED', + COURSES_COMPLETED: 'COURSES_COMPLETED', + DAYS_STREAK: 'DAYS_STREAK', + ASSESSMENT_SCORE: 'ASSESSMENT_SCORE', + DISCUSSION_POSTS: 'DISCUSSION_POSTS', + HELPFUL_VOTES: 'HELPFUL_VOTES', + COURSES_CREATED: 'COURSES_CREATED', + LESSONS_CREATED: 'LESSONS_CREATED', + LESSON_TIME: 'LESSON_TIME', + COMPLETION_TIME: 'COMPLETION_TIME', + PEER_REVIEWS: 'PEER_REVIEWS', + QUIZ_SCORE: 'QUIZ_SCORE', + FORUM_CONTRIBUTIONS: 'FORUM_CONTRIBUTIONS', + COURSE_RATING: 'COURSE_RATING', + COURSE_COMPLETION_RATE: 'COURSE_COMPLETION_RATE', +} as const; + +/** + * Default Reward by Difficulty + */ +export const DIFFICULTY_REWARDS = { + [AchievementDifficultyEnum.EASY]: { + points: 50, + experience: 25, + }, + [AchievementDifficultyEnum.MEDIUM]: { + points: 150, + experience: 100, + }, + [AchievementDifficultyEnum.HARD]: { + points: 400, + experience: 200, + }, + [AchievementDifficultyEnum.LEGENDARY]: { + points: 1500, + experience: 750, + }, +}; + +/** + * Achievement Query Options + */ +export interface AchievementQueryOptions { + type?: AchievementTypeEnum; + difficulty?: AchievementDifficultyEnum; + isActive?: boolean; + isHidden?: boolean; + sortBy?: 'difficulty' | 'createdAt' | 'unlockedBy' | 'pointsReward'; + sortOrder?: 'ASC' | 'DESC'; + limit?: number; + offset?: number; +} + +/** + * Statistics Query Options + */ +export interface StatisticsQueryOptions { + achievementId?: string; + startDate?: Date; + endDate?: Date; + timeframe?: StatisticsTimeframe; + minUnlocks?: number; + maxUnlocks?: number; +} + +/** + * Leaderboard Options + */ +export interface LeaderboardOptions { + limit?: number; + offset?: number; + sortBy?: 'totalAchievements' | 'totalPoints' | 'totalExperience' | 'unlockedRecently'; +} + +/** + * Bulk Operation Options + */ +export interface BulkOperationOptions { + dryRun?: boolean; // Test without actually saving + notifyUsers?: boolean; // Send notifications + logChanges?: boolean; // Log all changes +} diff --git a/src/achievements/dto/achievement-progress.dto.ts b/src/achievements/dto/achievement-progress.dto.ts new file mode 100644 index 0000000..7f18732 --- /dev/null +++ b/src/achievements/dto/achievement-progress.dto.ts @@ -0,0 +1,239 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AchievementsController } from '../achievements.controller'; +import { AchievementsService } from '../achievements.service'; +import { Achievement, AchievementType, AchievementDifficulty } from '../entities/achievement.entity'; +import { AchievementProgress } from '../entities/achievement-progress.entity'; +import { UserAchievement } from '../entities/user-achievement.entity'; +import { AchievementStatistics } from '../entities/achievement-statistics.entity'; + +describe('AchievementsController', () => { + let controller: AchievementsController; + let service: AchievementsService; + + const mockAchievementResponseDto = { + id: 'ach-123', + name: 'First Steps', + description: 'Complete your first lesson', + longDescription: 'A detailed description', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + isActive: true, + isHidden: false, + unlockedBy: 5, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AchievementsController], + providers: [ + { + provide: AchievementsService, + useValue: { + createAchievement: jest.fn(), + getAllAchievements: jest.fn(), + getAchievementsByType: jest.fn(), + getAchievementById: jest.fn(), + updateAchievement: jest.fn(), + deactivateAchievement: jest.fn(), + initializeProgress: jest.fn(), + getUserProgressForAchievement: jest.fn(), + updateProgress: jest.fn(), + incrementProgress: jest.fn(), + getUserAllProgress: jest.fn(), + unlockAchievement: jest.fn(), + getUserAchievements: jest.fn(), + hasAchievement: jest.fn(), + getUserAchievementCount: jest.fn(), + getAchievementStatistics: jest.fn(), + getUserAchievementOverview: jest.fn(), + getAchievementsLeaderboard: jest.fn(), + getAllAchievementsStatistics: jest.fn(), + batchUnlockAchievements: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AchievementsController); + service = module.get(AchievementsService); + }); + + describe('createAchievement', () => { + it('should create an achievement', async () => { + const createDto = { + name: 'First Steps', + description: 'Complete your first lesson', + iconUrl: 'https://example.com/icon.png', + type: AchievementType.MILESTONE, + difficulty: AchievementDifficulty.EASY, + pointsReward: 100, + experienceReward: 50, + criteria: { type: 'LESSONS_COMPLETED', target: 1 }, + progressConfig: { trackingType: 'incremental', maxProgress: 1 }, + }; + + jest.spyOn(service, 'createAchievement').mockResolvedValue(mockAchievementResponseDto); + + const result = await controller.createAchievement(createDto); + + expect(result).toEqual(mockAchievementResponseDto); + expect(service.createAchievement).toHaveBeenCalledWith(createDto); + }); + }); + + describe('getAllAchievements', () => { + it('should get all achievements', async () => { + jest.spyOn(service, 'getAllAchievements').mockResolvedValue([mockAchievementResponseDto]); + + const result = await controller.getAllAchievements(); + + expect(result).toEqual([mockAchievementResponseDto]); + expect(service.getAllAchievements).toHaveBeenCalledWith(false); + }); + }); + + describe('getAchievementById', () => { + it('should get achievement by id', async () => { + jest.spyOn(service, 'getAchievementById').mockResolvedValue(mockAchievementResponseDto); + + const result = await controller.getAchievementById('ach-123'); + + expect(result).toEqual(mockAchievementResponseDto); + expect(service.getAchievementById).toHaveBeenCalledWith('ach-123'); + }); + }); + + describe('updateAchievement', () => { + it('should update an achievement', async () => { + const updateDto = { name: 'Updated Name' }; + const updatedResponse = { ...mockAchievementResponseDto, name: 'Updated Name' }; + + jest.spyOn(service, 'updateAchievement').mockResolvedValue(updatedResponse); + + const result = await controller.updateAchievement('ach-123', updateDto); + + expect(result.name).toBe('Updated Name'); + expect(service.updateAchievement).toHaveBeenCalledWith('ach-123', updateDto); + }); + }); + + describe('unlockAchievement', () => { + it('should unlock an achievement', async () => { + const mockUnlockedEvent = { + userId: 'user-123', + achievementId: 'ach-123', + achievement: mockAchievementResponseDto, + pointsEarned: 100, + experienceEarned: 50, + unlockedAt: new Date(), + }; + + jest.spyOn(service, 'unlockAchievement').mockResolvedValue(mockUnlockedEvent); + + const result = await controller.unlockAchievement('ach-123', 'user-123'); + + expect(result.userId).toBe('user-123'); + expect(result.achievementId).toBe('ach-123'); + expect(service.unlockAchievement).toHaveBeenCalledWith('user-123', 'ach-123', undefined); + }); + }); + + describe('getUserAchievements', () => { + it('should get user achievements', async () => { + const mockUserAchievements = [ + { + id: 'ua-1', + userId: 'user-123', + achievementId: 'ach-123', + achievement: mockAchievementResponseDto, + unlockedAt: new Date(), + pointsEarned: 100, + experienceEarned: 50, + notificationSent: true, + isHidden: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + jest.spyOn(service, 'getUserAchievements').mockResolvedValue(mockUserAchievements); + + const result = await controller.getUserAchievements('user-123'); + + expect(result).toEqual(mockUserAchievements); + expect(service.getUserAchievements).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('hasAchievement', () => { + it('should check if user has achievement', async () => { + jest.spyOn(service, 'hasAchievement').mockResolvedValue(true); + + const result = await controller.hasAchievement('ach-123', 'user-123'); + + expect(result).toEqual({ hasAchievement: true }); + expect(service.hasAchievement).toHaveBeenCalledWith('user-123', 'ach-123'); + }); + }); + + describe('getUserAchievementCount', () => { + it('should get user achievement count', async () => { + jest.spyOn(service, 'getUserAchievementCount').mockResolvedValue(5); + + const result = await controller.getUserAchievementCount('user-123'); + + expect(result).toEqual({ count: 5 }); + expect(service.getUserAchievementCount).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('getAchievementsLeaderboard', () => { + it('should get achievements leaderboard', async () => { + const mockLeaderboard = [ + { + userId: 'user-1', + username: 'user1', + totalAchievements: 10, + totalPoints: 1000, + totalExperience: 500, + rank: 1, + }, + ]; + + jest.spyOn(service, 'getAchievementsLeaderboard').mockResolvedValue(mockLeaderboard); + + const result = await controller.getAchievementsLeaderboard('10'); + + expect(result).toEqual(mockLeaderboard); + expect(service.getAchievementsLeaderboard).toHaveBeenCalledWith(10); + }); + }); + + describe('getUserAchievementOverview', () => { + it('should get user achievement overview', async () => { + const mockOverview = { + totalAchievements: 10, + unlockedAchievements: 5, + progressPercentage: 50, + totalPointsEarned: 500, + totalExperienceEarned: 250, + userRank: 15, + }; + + jest.spyOn(service, 'getUserAchievementOverview').mockResolvedValue(mockOverview); + + const result = await controller.getUserAchievementOverview('user-123'); + + expect(result).toEqual(mockOverview); + expect(service.getUserAchievementOverview).toHaveBeenCalledWith('user-123'); + }); + }); +}); diff --git a/src/achievements/dto/achievement-statistics.dto.ts b/src/achievements/dto/achievement-statistics.dto.ts new file mode 100644 index 0000000..e926a80 --- /dev/null +++ b/src/achievements/dto/achievement-statistics.dto.ts @@ -0,0 +1,33 @@ +export class AchievementStatisticsDto { + id: string; + achievementId: string; + date: Date; + totalUnlocked: number; + unlockedToday: number; + unlockedPercentage: number; + averageTimeToUnlock?: number; + activeTrackers: number; + averageProgress: number; + engagementTrend?: 'positive' | 'negative' | 'stable'; + metadata?: any; + createdAt: Date; + updatedAt: Date; +} + +export class AchievementLeaderboardDto { + userId: string; + username: string; + totalAchievements: number; + totalPoints: number; + totalExperience: number; + rank: number; +} + +export class AchievementOverviewDto { + totalAchievements: number; + unlockedAchievements: number; + progressPercentage: number; + totalPointsEarned: number; + totalExperienceEarned: number; + userRank: number; +} diff --git a/src/achievements/dto/achievement.dto.ts b/src/achievements/dto/achievement.dto.ts new file mode 100644 index 0000000..bdeb643 --- /dev/null +++ b/src/achievements/dto/achievement.dto.ts @@ -0,0 +1,48 @@ +import { AchievementType, AchievementDifficulty } from '../entities/achievement.entity'; + +export class CreateAchievementDto { + name: string; + description: string; + longDescription?: string; + iconUrl: string; + type: AchievementType; + difficulty: AchievementDifficulty; + pointsReward: number; + experienceReward: number; + criteria: any; + progressConfig: any; +} + +export class UpdateAchievementDto { + name?: string; + description?: string; + longDescription?: string; + iconUrl?: string; + type?: AchievementType; + difficulty?: AchievementDifficulty; + pointsReward?: number; + experienceReward?: number; + criteria?: any; + progressConfig?: any; + isActive?: boolean; + isHidden?: boolean; +} + +export class AchievementResponseDto { + id: string; + name: string; + description: string; + longDescription?: string; + iconUrl: string; + type: AchievementType; + difficulty: AchievementDifficulty; + pointsReward: number; + experienceReward: number; + criteria: any; + progressConfig: any; + isActive: boolean; + isHidden: boolean; + unlockedBy?: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/achievements/dto/user-achievement.dto.ts b/src/achievements/dto/user-achievement.dto.ts new file mode 100644 index 0000000..20846a4 --- /dev/null +++ b/src/achievements/dto/user-achievement.dto.ts @@ -0,0 +1,25 @@ +import { AchievementResponseDto } from './achievement.dto'; + +export class UserAchievementDto { + id: string; + userId: string; + achievementId: string; + achievement: AchievementResponseDto; + unlockedAt: Date; + unlockedMetadata?: any; + pointsEarned: number; + experienceEarned: number; + notificationSent: boolean; + isHidden: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class AchievementUnlockedEventDto { + userId: string; + achievementId: string; + achievement: AchievementResponseDto; + pointsEarned: number; + experienceEarned: number; + unlockedAt: Date; +} diff --git a/src/achievements/entities/achievement-progress.entity.ts b/src/achievements/entities/achievement-progress.entity.ts new file mode 100644 index 0000000..cc72c68 --- /dev/null +++ b/src/achievements/entities/achievement-progress.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; +import { Achievement } from './achievement.entity'; +import { User } from '../../users/entities/user.entity'; + +/** + * Tracks a user's progress toward an achievement. + * Used for incremental progress tracking (e.g., lessons completed, streak days). + */ +@Entity('achievement_progress') +@Index(['user', 'achievement'], { unique: true }) +export class AchievementProgress { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @ManyToOne(() => User, { eager: true }) + @JoinColumn() + @Index() + user: User; + + @ManyToOne(() => Achievement, (achievement) => achievement.progresses, { + eager: true, + }) + @JoinColumn() + @Index() + achievement: Achievement; + + /** + * Current progress value (e.g., lessons completed count) + */ + @Column({ default: 0 }) + currentProgress: number; + + /** + * Maximum progress needed to unlock achievement + */ + @Column({ default: 0 }) + targetProgress: number; + + /** + * Percentage of completion (0-100) + */ + @Column({ default: 0 }) + percentageComplete: number; + + /** + * Whether the user has unlocked this achievement + */ + @Column({ default: false }) + @Index() + isUnlocked: boolean; + + /** + * Last update timestamp for progress + */ + @Column({ nullable: true }) + lastProgressUpdate?: Date; + + /** + * Additional metadata for progress tracking + */ + @Column('jsonb', { nullable: true }) + metadata?: any; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/achievements/entities/achievement-statistics.entity.ts b/src/achievements/entities/achievement-statistics.entity.ts new file mode 100644 index 0000000..421a216 --- /dev/null +++ b/src/achievements/entities/achievement-statistics.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +/** + * Tracks achievement system statistics for analytics and monitoring. + * Used to report on achievement unlock rates, trends, and user engagement. + */ +@Entity('achievement_statistics') +@Index(['achievementId', 'date']) +export class AchievementStatistics { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @Column() + @Index() + achievementId: string; + + /** + * Date of the statistic snapshot + */ + @Column({ type: 'date' }) + @Index() + date: Date; + + /** + * Total number of users who have unlocked this achievement + */ + @Column({ default: 0 }) + totalUnlocked: number; + + /** + * Number of users who unlocked today + */ + @Column({ default: 0 }) + unlockedToday: number; + + /** + * Percentage of total users who have this achievement + */ + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + unlockedPercentage: number; + + /** + * Average time to unlock (in days) + */ + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + averageTimeToUnlock?: number; + + /** + * Number of users currently tracking progress + */ + @Column({ default: 0 }) + activeTrackers: number; + + /** + * Average progress percentage for users tracking this achievement + */ + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + averageProgress: number; + + /** + * Engagement trend (positive/negative/stable) + */ + @Column({ nullable: true }) + engagementTrend?: 'positive' | 'negative' | 'stable'; + + /** + * Custom metadata for additional stats + */ + @Column('jsonb', { nullable: true }) + metadata?: any; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/achievements/entities/achievement.entity.ts b/src/achievements/entities/achievement.entity.ts new file mode 100644 index 0000000..879ff60 --- /dev/null +++ b/src/achievements/entities/achievement.entity.ts @@ -0,0 +1,104 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, + OneToMany, +} from 'typeorm'; +import { AchievementProgress } from './achievement-progress.entity'; +import { UserAchievement } from './user-achievement.entity'; + +export enum AchievementType { + MILESTONE = 'milestone', + CHALLENGE = 'challenge', + STREAKS = 'streaks', + SKILL_BASED = 'skill_based', + ENGAGEMENT = 'engagement', + CONTRIBUTION = 'contribution', +} + +export enum AchievementDifficulty { + EASY = 'easy', + MEDIUM = 'medium', + HARD = 'hard', + LEGENDARY = 'legendary', +} + +/** + * Represents an achievement definition. + * Achievements are goals that users can unlock by meeting specific criteria. + */ +@Entity('achievements') +export class Achievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @Column() + name: string; + + @Column() + description: string; + + @Column({ type: 'text', nullable: true }) + longDescription?: string; + + @Column() + iconUrl: string; + + @Column({ type: 'enum', enum: AchievementType }) + type: AchievementType; + + @Column({ type: 'enum', enum: AchievementDifficulty }) + difficulty: AchievementDifficulty; + + @Column({ default: 0 }) + pointsReward: number; + + @Column({ default: 100 }) + experienceReward: number; + + /** + * Criteria for unlocking the achievement + * Structure: { type: string, target: number, ... } + * Examples: + * { type: 'POINTS_REACHED', target: 1000 } + * { type: 'COURSES_COMPLETED', target: 5 } + * { type: 'DAYS_STREAK', target: 30 } + * { type: 'LESSONS_COMPLETED', target: 100 } + */ + @Column('jsonb', { nullable: true }) + criteria: any; + + /** + * Progress tracking configuration + * { trackingType: 'incremental' | 'binary', maxProgress: number } + */ + @Column('jsonb', { nullable: true }) + progressConfig: any; + + @Column({ default: false }) + isActive: boolean; + + @Column({ default: false }) + isHidden: boolean; + + @Column({ nullable: true }) + unlockedBy?: number; // Number of users who have unlocked this + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => AchievementProgress, (progress) => progress.achievement) + progresses: AchievementProgress[]; + + @OneToMany(() => UserAchievement, (userAch) => userAch.achievement) + userAchievements: UserAchievement[]; +} diff --git a/src/achievements/entities/user-achievement.entity.ts b/src/achievements/entities/user-achievement.entity.ts new file mode 100644 index 0000000..0c273a3 --- /dev/null +++ b/src/achievements/entities/user-achievement.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; +import { Achievement } from './achievement.entity'; +import { User } from '../../users/entities/user.entity'; + +/** + * Represents an achievement that has been unlocked by a user. + * Records the timestamp and metadata of when/how the achievement was earned. + */ +@Entity('user_achievements') +@Index(['user', 'achievement'], { unique: true }) +export class UserAchievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @ManyToOne(() => User, { eager: true }) + @JoinColumn() + @Index() + user: User; + + @ManyToOne(() => Achievement, (achievement) => achievement.userAchievements, { + eager: true, + }) + @JoinColumn() + @Index() + achievement: Achievement; + + /** + * Timestamp when the achievement was unlocked + */ + @Column() + @Index() + unlockedAt: Date; + + /** + * Additional metadata about how the achievement was earned + * e.g., { context: 'course_completion', relatedId: 'course-123' } + */ + @Column('jsonb', { nullable: true }) + unlockedMetadata?: any; + + /** + * Points earned from this achievement + */ + @Column({ default: 0 }) + pointsEarned: number; + + /** + * Experience earned from this achievement + */ + @Column({ default: 0 }) + experienceEarned: number; + + /** + * Whether a notification was sent to the user + */ + @Column({ default: false }) + notificationSent: boolean; + + /** + * Whether the achievement is hidden from the user + */ + @Column({ default: false }) + isHidden: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/achievements/migrations/1700000000000-CreateAchievementsSchema.ts b/src/achievements/migrations/1700000000000-CreateAchievementsSchema.ts new file mode 100644 index 0000000..af13566 --- /dev/null +++ b/src/achievements/migrations/1700000000000-CreateAchievementsSchema.ts @@ -0,0 +1,458 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateAchievementsSchema1700000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create achievements table + await queryRunner.createTable( + new Table({ + name: 'achievements', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'version', + type: 'integer', + default: 0, + }, + { + name: 'name', + type: 'varchar', + isNullable: false, + }, + { + name: 'description', + type: 'text', + isNullable: false, + }, + { + name: 'longDescription', + type: 'text', + isNullable: true, + }, + { + name: 'iconUrl', + type: 'varchar', + isNullable: false, + }, + { + name: 'type', + type: 'enum', + enum: ['milestone', 'challenge', 'streaks', 'skill_based', 'engagement', 'contribution'], + isNullable: false, + }, + { + name: 'difficulty', + type: 'enum', + enum: ['easy', 'medium', 'hard', 'legendary'], + isNullable: false, + }, + { + name: 'pointsReward', + type: 'integer', + default: 0, + }, + { + name: 'experienceReward', + type: 'integer', + default: 0, + }, + { + name: 'criteria', + type: 'jsonb', + isNullable: true, + }, + { + name: 'progressConfig', + type: 'jsonb', + isNullable: true, + }, + { + name: 'isActive', + type: 'boolean', + default: true, + }, + { + name: 'isHidden', + type: 'boolean', + default: false, + }, + { + name: 'unlockedBy', + type: 'integer', + default: 0, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + }), + ); + + // Create achievement_progress table + await queryRunner.createTable( + new Table({ + name: 'achievement_progress', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'version', + type: 'integer', + default: 0, + }, + { + name: 'userId', + type: 'uuid', + isNullable: false, + }, + { + name: 'achievementId', + type: 'uuid', + isNullable: false, + }, + { + name: 'currentProgress', + type: 'integer', + default: 0, + }, + { + name: 'targetProgress', + type: 'integer', + default: 0, + }, + { + name: 'percentageComplete', + type: 'integer', + default: 0, + }, + { + name: 'isUnlocked', + type: 'boolean', + default: false, + }, + { + name: 'lastProgressUpdate', + type: 'timestamp', + isNullable: true, + }, + { + name: 'metadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['achievementId'], + referencedTableName: 'achievements', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + ); + + // Create user_achievements table + await queryRunner.createTable( + new Table({ + name: 'user_achievements', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'version', + type: 'integer', + default: 0, + }, + { + name: 'userId', + type: 'uuid', + isNullable: false, + }, + { + name: 'achievementId', + type: 'uuid', + isNullable: false, + }, + { + name: 'unlockedAt', + type: 'timestamp', + isNullable: false, + }, + { + name: 'unlockedMetadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'pointsEarned', + type: 'integer', + default: 0, + }, + { + name: 'experienceEarned', + type: 'integer', + default: 0, + }, + { + name: 'notificationSent', + type: 'boolean', + default: false, + }, + { + name: 'isHidden', + type: 'boolean', + default: false, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['achievementId'], + referencedTableName: 'achievements', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + ); + + // Create achievement_statistics table + await queryRunner.createTable( + new Table({ + name: 'achievement_statistics', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'version', + type: 'integer', + default: 0, + }, + { + name: 'achievementId', + type: 'uuid', + isNullable: false, + }, + { + name: 'date', + type: 'date', + isNullable: false, + }, + { + name: 'totalUnlocked', + type: 'integer', + default: 0, + }, + { + name: 'unlockedToday', + type: 'integer', + default: 0, + }, + { + name: 'unlockedPercentage', + type: 'numeric', + precision: 5, + scale: 2, + default: 0, + }, + { + name: 'averageTimeToUnlock', + type: 'numeric', + precision: 10, + scale: 2, + isNullable: true, + }, + { + name: 'activeTrackers', + type: 'integer', + default: 0, + }, + { + name: 'averageProgress', + type: 'numeric', + precision: 5, + scale: 2, + default: 0, + }, + { + name: 'engagementTrend', + type: 'varchar', + isNullable: true, + }, + { + name: 'metadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + columnNames: ['achievementId'], + referencedTableName: 'achievements', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + ); + + // Create indexes + await queryRunner.createIndex( + 'achievement_progress', + new TableIndex({ + name: 'IDX_achievement_progress_user_achievement', + columnNames: ['userId', 'achievementId'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'achievement_progress', + new TableIndex({ + name: 'IDX_achievement_progress_user', + columnNames: ['userId'], + }), + ); + + await queryRunner.createIndex( + 'achievement_progress', + new TableIndex({ + name: 'IDX_achievement_progress_achievement', + columnNames: ['achievementId'], + }), + ); + + await queryRunner.createIndex( + 'achievement_progress', + new TableIndex({ + name: 'IDX_achievement_progress_unlocked', + columnNames: ['isUnlocked'], + }), + ); + + await queryRunner.createIndex( + 'user_achievements', + new TableIndex({ + name: 'IDX_user_achievements_user_achievement', + columnNames: ['userId', 'achievementId'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'user_achievements', + new TableIndex({ + name: 'IDX_user_achievements_user', + columnNames: ['userId'], + }), + ); + + await queryRunner.createIndex( + 'user_achievements', + new TableIndex({ + name: 'IDX_user_achievements_achievement', + columnNames: ['achievementId'], + }), + ); + + await queryRunner.createIndex( + 'user_achievements', + new TableIndex({ + name: 'IDX_user_achievements_unlocked_at', + columnNames: ['unlockedAt'], + }), + ); + + await queryRunner.createIndex( + 'achievement_statistics', + new TableIndex({ + name: 'IDX_achievement_statistics_achievement_date', + columnNames: ['achievementId', 'date'], + }), + ); + + await queryRunner.createIndex( + 'achievement_statistics', + new TableIndex({ + name: 'IDX_achievement_statistics_date', + columnNames: ['date'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('achievement_statistics'); + await queryRunner.dropTable('user_achievements'); + await queryRunner.dropTable('achievement_progress'); + await queryRunner.dropTable('achievements'); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 971f7f2..fd933e4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,8 @@ import { CanaryModule } from './canary/canary.module'; import { IncidentManagementModule } from './incident-management/incident-management.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { I18nModule as AppI18nModule } from './i18n/i18n.module'; +import { AchievementsModule } from './achievements/achievements.module'; + // ✅ keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; @@ -51,6 +53,7 @@ const featureFlags = loadFeatureFlags(); ...(featureFlags.ENABLE_CACHING ? [CachingModule] : []), // i18n support AppI18nModule, + AchievementsModule, ], controllers: [AppController], providers: featureFlags.ENABLE_RATE_LIMITING