From 2790c9d320321846dfb701825900ceef048dc39c Mon Sep 17 00:00:00 2001 From: LaGodxy Date: Sat, 5 Jul 2025 21:26:30 -0700 Subject: [PATCH] Build Event-Driven Daily Streak System with Gamified Milestone Rewards --- src/app.module.ts | 2 + .../20250601204323-CreateDailyStreaksTable.ts | 43 +++ src/gamification/README.md | 232 +++++++++++ .../constants/streak.constants.ts | 19 + .../controllers/streak.controller.spec.ts | 128 ++++++ .../controllers/streak.controller.ts | 61 +++ src/gamification/dto/bonus-reward.dto.ts | 6 +- src/gamification/dto/puzzle-submission.dto.ts | 8 +- src/gamification/dto/streak.dto.ts | 67 ++++ .../entities/daily-streak.entity.ts | 43 +++ src/gamification/gamification.module.ts | 13 +- src/gamification/gamification.service.ts | 6 +- .../listeners/streak.listener.spec.ts | 122 ++++++ src/gamification/listeners/streak.listener.ts | 39 ++ .../providers/daily-streak.service.spec.ts | 365 ++++++++++++++++++ .../providers/daily-streak.service.ts | 244 ++++++++++++ .../providers/iq-assessment.service.ts | 10 + src/puzzle/entities/puzzle-progress.entity.ts | 57 ++- .../entities/puzzle-submission.entity.ts | 94 +++-- src/puzzle/puzzle.controller.ts | 72 +++- src/puzzle/puzzle.module.ts | 12 +- src/puzzle/puzzle.service.spec.ts | 346 +++++++++++++++++ src/puzzle/puzzle.service.ts | 199 ++++++++++ 23 files changed, 2097 insertions(+), 91 deletions(-) create mode 100644 src/database/migrations/20250601204323-CreateDailyStreaksTable.ts create mode 100644 src/gamification/README.md create mode 100644 src/gamification/constants/streak.constants.ts create mode 100644 src/gamification/controllers/streak.controller.spec.ts create mode 100644 src/gamification/controllers/streak.controller.ts create mode 100644 src/gamification/dto/streak.dto.ts create mode 100644 src/gamification/entities/daily-streak.entity.ts create mode 100644 src/gamification/listeners/streak.listener.spec.ts create mode 100644 src/gamification/listeners/streak.listener.ts create mode 100644 src/gamification/providers/daily-streak.service.spec.ts create mode 100644 src/gamification/providers/daily-streak.service.ts create mode 100644 src/puzzle/puzzle.service.spec.ts diff --git a/src/app.module.ts b/src/app.module.ts index d08e94f..007200f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { AuthModule } from './auth/auth.module'; import appConfig from './config/app.config'; @@ -29,6 +30,7 @@ import { GamificationModule } from './gamification/gamification.module'; envFilePath: ['.env'], load: [appConfig, databaseConfig], }), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/src/database/migrations/20250601204323-CreateDailyStreaksTable.ts b/src/database/migrations/20250601204323-CreateDailyStreaksTable.ts new file mode 100644 index 0000000..b55512e --- /dev/null +++ b/src/database/migrations/20250601204323-CreateDailyStreaksTable.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateDailyStreaksTable20250601204323 implements MigrationInterface { + name = 'CreateDailyStreaksTable20250601204323'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "daily_streaks" ( + "id" SERIAL NOT NULL, + "user_id" integer NOT NULL, + "last_active_date" date NOT NULL, + "streak_count" integer NOT NULL DEFAULT 0, + "longest_streak" integer NOT NULL DEFAULT 0, + "last_milestone_reached" integer, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_daily_streaks_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_daily_streaks_user_id" UNIQUE ("user_id") + ); + + ALTER TABLE "daily_streaks" + ADD CONSTRAINT "FK_daily_streaks_user_id" + FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE; + + CREATE INDEX "IDX_daily_streaks_user_id" ON "daily_streaks" ("user_id"); + CREATE INDEX "IDX_daily_streaks_streak_count" ON "daily_streaks" ("streak_count" DESC); + CREATE INDEX "IDX_daily_streaks_longest_streak" ON "daily_streaks" ("longest_streak" DESC); + CREATE INDEX "IDX_daily_streaks_last_active_date" ON "daily_streaks" ("last_active_date" DESC); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "IDX_daily_streaks_last_active_date"; + DROP INDEX "IDX_daily_streaks_longest_streak"; + DROP INDEX "IDX_daily_streaks_streak_count"; + DROP INDEX "IDX_daily_streaks_user_id"; + + ALTER TABLE "daily_streaks" DROP CONSTRAINT "FK_daily_streaks_user_id"; + DROP TABLE "daily_streaks"; + `); + } +} \ No newline at end of file diff --git a/src/gamification/README.md b/src/gamification/README.md new file mode 100644 index 0000000..6747ecc --- /dev/null +++ b/src/gamification/README.md @@ -0,0 +1,232 @@ +# Daily Streak System + +A comprehensive daily streak system that rewards users for solving at least one puzzle per day. This system tracks consecutive days of participation, resets if a day is missed, and provides bonus XP or tokens for milestone streaks. + +## Features + +- **Daily Streak Tracking**: Tracks consecutive days of puzzle solving +- **Milestone Rewards**: Awards bonus XP and tokens for reaching streak milestones +- **Event-Driven Architecture**: Uses NestJS event emitter for loose coupling +- **Leaderboard**: Shows top streak holders +- **Statistics**: Provides admin statistics for streak analytics +- **Automatic Integration**: Works with both puzzle and IQ assessment systems + +## Architecture + +### Components + +1. **DailyStreak Entity** (`entities/daily-streak.entity.ts`) + - Stores user streak data + - Tracks current streak, longest streak, and milestone progress + +2. **DailyStreakService** (`providers/daily-streak.service.ts`) + - Core business logic for streak management + - Handles streak updates, milestone checking, and leaderboard queries + +3. **StreakController** (`controllers/streak.controller.ts`) + - REST API endpoints for streak operations + - Protected with JWT authentication + +4. **StreakListener** (`listeners/streak.listener.ts`) + - Event listener for puzzle and IQ question submissions + - Automatically updates streaks when users solve puzzles correctly + +5. **Constants** (`constants/streak.constants.ts`) + - Configuration for milestones and rewards + - Event names and system configuration + +## API Endpoints + +### Get Current Streak +``` +GET /streak +Authorization: Bearer +``` + +**Response:** +```json +{ + "streakCount": 5, + "longestStreak": 10, + "lastActiveDate": "2024-01-15T00:00:00.000Z", + "hasSolvedToday": true, + "nextMilestone": 7, + "daysUntilNextMilestone": 2 +} +``` + +### Get Streak Leaderboard +``` +GET /streak/leaderboard?page=1&limit=10 +Authorization: Bearer +``` + +**Response:** +```json +{ + "entries": [ + { + "userId": 1, + "username": "user1", + "streakCount": 15, + "longestStreak": 20, + "lastActiveDate": "2024-01-15T00:00:00.000Z" + } + ], + "total": 1, + "page": 1, + "limit": 10 +} +``` + +### Get Streak Statistics (Admin) +``` +GET /streak/stats +Authorization: Bearer +``` + +**Response:** +```json +{ + "totalUsers": 100, + "activeUsers": 50, + "averageStreak": 5, + "topStreak": 30 +} +``` + +## Milestone System + +The system awards bonus rewards at the following milestones: + +| Streak Days | XP Reward | Token Reward | Description | +|-------------|-----------|--------------|-------------| +| 3 | 50 | 5 | 3-Day Streak | +| 7 | 150 | 15 | 7-Day Streak | +| 14 | 300 | 30 | 14-Day Streak | +| 30 | 600 | 60 | 30-Day Streak | +| 60 | 1200 | 120 | 60-Day Streak | +| 100 | 2000 | 200 | 100-Day Streak | + +## Event System + +The streak system uses events for loose coupling: + +### Events Emitted +- `puzzle.submitted`: When a puzzle is submitted +- `iq.question.answered`: When an IQ question is answered +- `streak.puzzle.solved`: When a streak is updated +- `streak.milestone.reached`: When a milestone is reached + +### Event Listeners +- `StreakListener`: Listens for puzzle and IQ question events +- `GamificationService`: Listens for milestone events to award rewards + +## Database Schema + +### daily_streaks Table +```sql +CREATE TABLE daily_streaks ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE, + last_active_date DATE NOT NULL, + streak_count INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_milestone_reached INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE +); +``` + +## Integration + +### Puzzle System Integration +The streak system automatically integrates with the puzzle submission system: + +1. User submits puzzle solution +2. `PuzzleService` emits `puzzle.submitted` event +3. `StreakListener` handles the event +4. If solution is correct, streak is updated +5. If milestone is reached, bonus rewards are awarded + +### IQ Assessment Integration +Similar integration with IQ assessment system: + +1. User answers IQ question +2. `IQAssessmentService` emits `iq.question.answered` event +3. `StreakListener` handles the event +4. If answer is correct, streak is updated + +## Testing + +The system includes comprehensive unit tests: + +- `daily-streak.service.spec.ts`: Tests for core streak logic +- `streak.listener.spec.ts`: Tests for event handling +- `streak.controller.spec.ts`: Tests for API endpoints +- `puzzle.service.spec.ts`: Tests for puzzle integration + +Run tests with: +```bash +npm run test src/gamification +``` + +## Configuration + +### Environment Variables +No additional environment variables required. The system uses existing database and JWT configuration. + +### Customization +To modify milestone rewards, update `src/gamification/constants/streak.constants.ts`: + +```typescript +export const STREAK_MILESTONES = { + 3: { xp: 50, tokens: 5, description: '3-Day Streak' }, + // Add or modify milestones here +}; +``` + +## Usage Examples + +### Frontend Integration +```typescript +// Get current user streak +const streak = await api.get('/streak'); + +// Display streak information +console.log(`Current streak: ${streak.streakCount} days`); +console.log(`Longest streak: ${streak.longestStreak} days`); +console.log(`Next milestone: ${streak.nextMilestone} days`); + +// Get leaderboard +const leaderboard = await api.get('/streak/leaderboard?page=1&limit=10'); +``` + +### Backend Integration +```typescript +// Inject DailyStreakService +constructor(private readonly streakService: DailyStreakService) {} + +// Update streak manually (if needed) +const streak = await this.streakService.updateStreak(userId); + +// Get streak statistics +const stats = await this.streakService.getStreakStats(); +``` + +## Best Practices + +1. **Event-Driven**: Use events for loose coupling between systems +2. **Error Handling**: Streak updates should not break puzzle submission flow +3. **Idempotency**: Users can only update streak once per day +4. **Performance**: Use database indexes for leaderboard queries +5. **Testing**: Comprehensive test coverage for all business logic + +## Future Enhancements + +- **Streak Multipliers**: Bonus multipliers for longer streaks +- **Streak Challenges**: Special challenges for maintaining streaks +- **Streak Analytics**: More detailed analytics and insights +- **Streak Notifications**: Push notifications for streak reminders +- **Streak Sharing**: Social features for sharing streak achievements \ No newline at end of file diff --git a/src/gamification/constants/streak.constants.ts b/src/gamification/constants/streak.constants.ts new file mode 100644 index 0000000..6aaec7f --- /dev/null +++ b/src/gamification/constants/streak.constants.ts @@ -0,0 +1,19 @@ +export const STREAK_MILESTONES = { + 3: { xp: 50, tokens: 5, description: '3-Day Streak' }, + 7: { xp: 150, tokens: 15, description: '7-Day Streak' }, + 14: { xp: 300, tokens: 30, description: '14-Day Streak' }, + 30: { xp: 600, tokens: 60, description: '30-Day Streak' }, + 60: { xp: 1200, tokens: 120, description: '60-Day Streak' }, + 100: { xp: 2000, tokens: 200, description: '100-Day Streak' }, +} as const; + +export const STREAK_EVENTS = { + PUZZLE_SOLVED: 'streak.puzzle.solved', + MILESTONE_REACHED: 'streak.milestone.reached', +} as const; + +export const STREAK_CONFIG = { + MAX_STREAK_MILESTONE: 100, + BASE_XP_PER_PUZZLE: 10, + BASE_TOKENS_PER_PUZZLE: 1, +} as const; \ No newline at end of file diff --git a/src/gamification/controllers/streak.controller.spec.ts b/src/gamification/controllers/streak.controller.spec.ts new file mode 100644 index 0000000..dafe643 --- /dev/null +++ b/src/gamification/controllers/streak.controller.spec.ts @@ -0,0 +1,128 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StreakController } from './streak.controller'; +import { DailyStreakService } from '../providers/daily-streak.service'; +import { StreakResponseDto, StreakLeaderboardResponseDto } from '../dto/streak.dto'; + +describe('StreakController', () => { + let controller: StreakController; + let streakService: DailyStreakService; + + const mockStreakService = { + getStreak: jest.fn(), + getStreakLeaderboard: jest.fn(), + getStreakStats: jest.fn(), + }; + + const mockUser = { + sub: 1, + email: 'test@example.com', + username: 'testuser', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StreakController], + providers: [ + { + provide: DailyStreakService, + useValue: mockStreakService, + }, + ], + }).compile(); + + controller = module.get(StreakController); + streakService = module.get(DailyStreakService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getCurrentStreak', () => { + it('should return current user streak', async () => { + const mockStreakResponse: StreakResponseDto = { + streakCount: 5, + longestStreak: 10, + lastActiveDate: new Date(), + hasSolvedToday: true, + nextMilestone: 7, + daysUntilNextMilestone: 2, + }; + + mockStreakService.getStreak.mockResolvedValue(mockStreakResponse); + + const result = await controller.getCurrentStreak(mockUser as any); + + expect(mockStreakService.getStreak).toHaveBeenCalledWith(1); + expect(result).toEqual(mockStreakResponse); + }); + }); + + describe('getStreakLeaderboard', () => { + it('should return streak leaderboard', async () => { + const query = { page: 1, limit: 10 }; + const mockLeaderboardResponse: StreakLeaderboardResponseDto = { + entries: [ + { + userId: 1, + username: 'user1', + streakCount: 10, + longestStreak: 15, + lastActiveDate: new Date(), + }, + { + userId: 2, + username: 'user2', + streakCount: 8, + longestStreak: 12, + lastActiveDate: new Date(), + }, + ], + total: 2, + page: 1, + limit: 10, + }; + + mockStreakService.getStreakLeaderboard.mockResolvedValue(mockLeaderboardResponse); + + const result = await controller.getStreakLeaderboard(query); + + expect(mockStreakService.getStreakLeaderboard).toHaveBeenCalledWith(query); + expect(result).toEqual(mockLeaderboardResponse); + }); + + it('should use default pagination when no query provided', async () => { + const mockLeaderboardResponse: StreakLeaderboardResponseDto = { + entries: [], + total: 0, + page: 1, + limit: 10, + }; + + mockStreakService.getStreakLeaderboard.mockResolvedValue(mockLeaderboardResponse); + + const result = await controller.getStreakLeaderboard({}); + + expect(mockStreakService.getStreakLeaderboard).toHaveBeenCalledWith({}); + expect(result).toEqual(mockLeaderboardResponse); + }); + }); + + describe('getStreakStats', () => { + it('should return streak statistics', async () => { + const mockStats = { + totalUsers: 100, + activeUsers: 50, + averageStreak: 5, + topStreak: 30, + }; + + mockStreakService.getStreakStats.mockResolvedValue(mockStats); + + const result = await controller.getStreakStats(); + + expect(mockStreakService.getStreakStats).toHaveBeenCalled(); + expect(result).toEqual(mockStats); + }); + }); +}); \ No newline at end of file diff --git a/src/gamification/controllers/streak.controller.ts b/src/gamification/controllers/streak.controller.ts new file mode 100644 index 0000000..dead2f7 --- /dev/null +++ b/src/gamification/controllers/streak.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { DailyStreakService } from '../providers/daily-streak.service'; +import { StreakResponseDto, StreakLeaderboardResponseDto, StreakQueryDto } from '../dto/streak.dto'; +import { ActiveUser } from '../../auth/decorators/activeUser.decorator'; +import { ActiveUserData } from '../../auth/interfaces/activeInterface'; + +@ApiTags('Daily Streak') +@Controller('streak') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class StreakController { + constructor(private readonly streakService: DailyStreakService) {} + + @Get() + @ApiOperation({ summary: 'Get current user streak status' }) + @ApiResponse({ + status: 200, + description: 'Current streak information', + type: StreakResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getCurrentStreak(@ActiveUser() user: ActiveUserData): Promise { + return this.streakService.getStreak(user.sub.toString()); + } + + @Get('leaderboard') + @ApiOperation({ summary: 'Get streak leaderboard' }) + @ApiResponse({ + status: 200, + description: 'Streak leaderboard', + type: StreakLeaderboardResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page' }) + async getStreakLeaderboard(@Query() query: StreakQueryDto): Promise { + return this.streakService.getStreakLeaderboard(query); + } + + @Get('stats') + @ApiOperation({ summary: 'Get streak statistics (Admin only)' }) + @ApiResponse({ + status: 200, + description: 'Streak statistics', + schema: { + type: 'object', + properties: { + totalUsers: { type: 'number' }, + activeUsers: { type: 'number' }, + averageStreak: { type: 'number' }, + topStreak: { type: 'number' }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getStreakStats() { + return this.streakService.getStreakStats(); + } +} \ No newline at end of file diff --git a/src/gamification/dto/bonus-reward.dto.ts b/src/gamification/dto/bonus-reward.dto.ts index 7d0c1a7..81548d1 100644 --- a/src/gamification/dto/bonus-reward.dto.ts +++ b/src/gamification/dto/bonus-reward.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsInt, IsPositive, IsString, Min } from 'class-validator'; export class BonusRewardDto { - @ApiProperty({ example: 1 }) - @IsInt() - userId: number; + @ApiProperty({ example: "1" }) + @IsString() + userId: string; @ApiProperty({ example: 100 }) @IsInt() diff --git a/src/gamification/dto/puzzle-submission.dto.ts b/src/gamification/dto/puzzle-submission.dto.ts index 1ca055b..12c7eae 100644 --- a/src/gamification/dto/puzzle-submission.dto.ts +++ b/src/gamification/dto/puzzle-submission.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsDate, IsInt } from 'class-validator'; +import { IsBoolean, IsDate, IsInt, IsString } from 'class-validator'; import { Type } from 'class-transformer'; export class PuzzleSubmissionDto { - @ApiProperty({ example: 1 }) - @IsInt() - userId: number; + @ApiProperty({ example: "1" }) + @IsString() + userId: string; @ApiProperty({ example: 101 }) @IsInt() diff --git a/src/gamification/dto/streak.dto.ts b/src/gamification/dto/streak.dto.ts new file mode 100644 index 0000000..3e89f6f --- /dev/null +++ b/src/gamification/dto/streak.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsInt, Min } from 'class-validator'; + +export class StreakResponseDto { + @ApiProperty({ description: 'Current consecutive days streak count' }) + streakCount: number; + + @ApiProperty({ description: 'Longest streak achieved by the user' }) + longestStreak: number; + + @ApiProperty({ description: 'Last date the user was active', required: false }) + lastActiveDate: Date | null; + + @ApiProperty({ description: 'Whether the user has already solved a puzzle today' }) + hasSolvedToday: boolean; + + @ApiProperty({ description: 'Next milestone to reach', required: false }) + nextMilestone?: number; + + @ApiProperty({ description: 'Days until next milestone', required: false }) + daysUntilNextMilestone?: number; +} + +export class StreakLeaderboardEntryDto { + @ApiProperty({ description: 'User ID' }) + userId: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Current streak count' }) + streakCount: number; + + @ApiProperty({ description: 'Longest streak achieved' }) + longestStreak: number; + + @ApiProperty({ description: 'Last active date' }) + lastActiveDate: Date; +} + +export class StreakLeaderboardResponseDto { + @ApiProperty({ description: 'List of top streak holders', type: [StreakLeaderboardEntryDto] }) + entries: StreakLeaderboardEntryDto[]; + + @ApiProperty({ description: 'Total number of entries' }) + total: number; + + @ApiProperty({ description: 'Current page number' }) + page: number; + + @ApiProperty({ description: 'Number of entries per page' }) + limit: number; +} + +export class StreakQueryDto { + @ApiProperty({ description: 'Page number', required: false, default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ description: 'Number of entries per page', required: false, default: 10 }) + @IsOptional() + @IsInt() + @Min(1) + limit?: number = 10; +} \ No newline at end of file diff --git a/src/gamification/entities/daily-streak.entity.ts b/src/gamification/entities/daily-streak.entity.ts new file mode 100644 index 0000000..46a30f6 --- /dev/null +++ b/src/gamification/entities/daily-streak.entity.ts @@ -0,0 +1,43 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '../../users/user.entity'; + +@Entity('daily_streaks') +export class DailyStreak { + @PrimaryGeneratedColumn() + @ApiProperty({ description: 'Unique identifier for the streak record' }) + id: number; + + @Column({ name: 'user_id' }) + @ApiProperty({ description: 'User ID associated with this streak' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + @ApiProperty({ type: () => User, description: 'User associated with this streak' }) + user: User; + + @Column({ name: 'last_active_date', type: 'date' }) + @ApiProperty({ description: 'Last date the user was active (solved a puzzle)' }) + lastActiveDate: Date; + + @Column({ name: 'streak_count', default: 0 }) + @ApiProperty({ description: 'Current consecutive days streak count' }) + streakCount: number; + + @Column({ name: 'longest_streak', default: 0 }) + @ApiProperty({ description: 'Longest streak achieved by the user' }) + longestStreak: number; + + @Column({ name: 'last_milestone_reached', type: 'int', nullable: true }) + @ApiProperty({ description: 'Last milestone streak count that was rewarded', required: false }) + lastMilestoneReached: number | null; + + @CreateDateColumn({ name: 'created_at' }) + @ApiProperty({ description: 'When the streak record was created' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + @ApiProperty({ description: 'When the streak record was last updated' }) + updatedAt: Date; +} \ No newline at end of file diff --git a/src/gamification/gamification.module.ts b/src/gamification/gamification.module.ts index 396feb0..7c8709b 100644 --- a/src/gamification/gamification.module.ts +++ b/src/gamification/gamification.module.ts @@ -1,14 +1,21 @@ import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { GamificationService } from './gamification.service'; import { PuzzleModule } from 'src/puzzle/puzzle.module'; import { GamificationController } from './gamification.controller'; +import { StreakController } from './controllers/streak.controller'; +import { DailyStreakService } from './providers/daily-streak.service'; +import { StreakListener } from './listeners/streak.listener'; +import { DailyStreak } from './entities/daily-streak.entity'; +import { User } from '../users/user.entity'; @Module({ imports: [ forwardRef(() => PuzzleModule), + TypeOrmModule.forFeature([DailyStreak, User]), ], - controllers: [GamificationController], - providers: [GamificationService], - exports: [GamificationService], + controllers: [GamificationController, StreakController], + providers: [GamificationService, DailyStreakService, StreakListener], + exports: [GamificationService, DailyStreakService], }) export class GamificationModule {} diff --git a/src/gamification/gamification.service.ts b/src/gamification/gamification.service.ts index e570e77..6aa126c 100644 --- a/src/gamification/gamification.service.ts +++ b/src/gamification/gamification.service.ts @@ -32,7 +32,7 @@ export class GamificationService { } private async awardXP( - userId: number, + userId: string, xp: number, reason: string, ): Promise { @@ -42,7 +42,7 @@ export class GamificationService { } private async awardTokens( - userId: number, + userId: string, tokens: number, reason: string, ): Promise { @@ -54,7 +54,7 @@ export class GamificationService { } private async processPuzzleRewards( - userId: number, + userId: string, puzzleId: number, isCorrect: boolean, ): Promise { diff --git a/src/gamification/listeners/streak.listener.spec.ts b/src/gamification/listeners/streak.listener.spec.ts new file mode 100644 index 0000000..f51297c --- /dev/null +++ b/src/gamification/listeners/streak.listener.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StreakListener } from './streak.listener'; +import { DailyStreakService } from '../providers/daily-streak.service'; +import { PuzzleSubmissionDto } from '../dto/puzzle-submission.dto'; + +describe('StreakListener', () => { + let listener: StreakListener; + let streakService: DailyStreakService; + + const mockStreakService = { + updateStreak: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StreakListener, + { + provide: DailyStreakService, + useValue: mockStreakService, + }, + ], + }).compile(); + + listener = module.get(StreakListener); + streakService = module.get(DailyStreakService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handlePuzzleSubmission', () => { + it('should update streak when puzzle is solved correctly', async () => { + const puzzleSubmission: PuzzleSubmissionDto = { + userId: 1, + puzzleId: 101, + isCorrect: true, + timestamp: new Date(), + }; + + mockStreakService.updateStreak.mockResolvedValue({ + streakCount: 5, + longestStreak: 10, + hasSolvedToday: true, + }); + + await listener.handlePuzzleSubmission(puzzleSubmission); + + expect(mockStreakService.updateStreak).toHaveBeenCalledWith(1); + }); + + it('should not update streak when puzzle is solved incorrectly', async () => { + const puzzleSubmission: PuzzleSubmissionDto = { + userId: 1, + puzzleId: 101, + isCorrect: false, + timestamp: new Date(), + }; + + await listener.handlePuzzleSubmission(puzzleSubmission); + + expect(mockStreakService.updateStreak).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const puzzleSubmission: PuzzleSubmissionDto = { + userId: 1, + puzzleId: 101, + isCorrect: true, + timestamp: new Date(), + }; + + mockStreakService.updateStreak.mockRejectedValue(new Error('Database error')); + + // Should not throw error + await expect(listener.handlePuzzleSubmission(puzzleSubmission)).resolves.not.toThrow(); + }); + }); + + describe('handleIQQuestionAnswered', () => { + it('should update streak when IQ question is answered correctly', async () => { + const iqData = { + userId: 1, + isCorrect: true, + }; + + mockStreakService.updateStreak.mockResolvedValue({ + streakCount: 3, + longestStreak: 7, + hasSolvedToday: true, + }); + + await listener.handleIQQuestionAnswered(iqData); + + expect(mockStreakService.updateStreak).toHaveBeenCalledWith(1); + }); + + it('should not update streak when IQ question is answered incorrectly', async () => { + const iqData = { + userId: 1, + isCorrect: false, + }; + + await listener.handleIQQuestionAnswered(iqData); + + expect(mockStreakService.updateStreak).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully for IQ questions', async () => { + const iqData = { + userId: 1, + isCorrect: true, + }; + + mockStreakService.updateStreak.mockRejectedValue(new Error('Database error')); + + // Should not throw error + await expect(listener.handleIQQuestionAnswered(iqData)).resolves.not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/src/gamification/listeners/streak.listener.ts b/src/gamification/listeners/streak.listener.ts new file mode 100644 index 0000000..adb8462 --- /dev/null +++ b/src/gamification/listeners/streak.listener.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DailyStreakService } from '../providers/daily-streak.service'; +import { PuzzleSubmissionDto } from '../dto/puzzle-submission.dto'; + +@Injectable() +export class StreakListener { + private readonly logger = new Logger(StreakListener.name); + + constructor(private readonly streakService: DailyStreakService) {} + + @OnEvent('puzzle.submitted') + async handlePuzzleSubmission(puzzleSubmission: PuzzleSubmissionDto): Promise { + try { + // Only update streak if the puzzle was solved correctly + if (puzzleSubmission.isCorrect) { + await this.streakService.updateStreak(puzzleSubmission.userId.toString()); + this.logger.log(`Streak updated for user ${puzzleSubmission.userId} after puzzle submission`); + } + } catch (error) { + this.logger.error(`Failed to update streak for user ${puzzleSubmission.userId}:`, error); + // Don't throw error to avoid breaking the puzzle submission flow + } + } + + @OnEvent('iq.question.answered') + async handleIQQuestionAnswered(data: { userId: string; isCorrect: boolean }): Promise { + try { + // Only update streak if the question was answered correctly + if (data.isCorrect) { + await this.streakService.updateStreak(data.userId.toString()); + this.logger.log(`Streak updated for user ${data.userId} after IQ question`); + } + } catch (error) { + this.logger.error(`Failed to update streak for user ${data.userId}:`, error); + // Don't throw error to avoid breaking the IQ assessment flow + } + } +} \ No newline at end of file diff --git a/src/gamification/providers/daily-streak.service.spec.ts b/src/gamification/providers/daily-streak.service.spec.ts new file mode 100644 index 0000000..ad498b6 --- /dev/null +++ b/src/gamification/providers/daily-streak.service.spec.ts @@ -0,0 +1,365 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DailyStreakService } from './daily-streak.service'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { User } from '../../users/user.entity'; +import { STREAK_MILESTONES } from '../constants/streak.constants'; + +describe('DailyStreakService', () => { + let service: DailyStreakService; + let streakRepository: Repository; + let userRepository: Repository; + let eventEmitter: EventEmitter2; + + const mockStreakRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + })), + }; + + const mockUserRepository = { + count: jest.fn(), + }; + + const mockEventEmitter = { + emit: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyStreakService, + { + provide: getRepositoryToken(DailyStreak), + useValue: mockStreakRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + ], + }).compile(); + + service = module.get(DailyStreakService); + streakRepository = module.get>(getRepositoryToken(DailyStreak)); + userRepository = module.get>(getRepositoryToken(User)); + eventEmitter = module.get(EventEmitter2); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateStreak', () => { + it('should create new streak for first-time user', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + mockStreakRepository.findOne.mockResolvedValue(null); + mockStreakRepository.create.mockReturnValue({ + userId, + lastActiveDate: today, + streakCount: 1, + longestStreak: 1, + lastMilestoneReached: null, + }); + mockStreakRepository.save.mockResolvedValue({ + id: 1, + userId, + lastActiveDate: today, + streakCount: 1, + longestStreak: 1, + lastMilestoneReached: null, + }); + + const result = await service.updateStreak(userId); + + expect(mockStreakRepository.findOne).toHaveBeenCalledWith({ + where: { userId }, + relations: ['user'], + }); + expect(mockStreakRepository.create).toHaveBeenCalledWith({ + userId, + lastActiveDate: today, + streakCount: 1, + longestStreak: 1, + lastMilestoneReached: null, + }); + expect(mockStreakRepository.save).toHaveBeenCalled(); + expect(mockEventEmitter.emit).toHaveBeenCalledWith('streak.puzzle.solved', { + userId, + streakCount: 1, + isNewStreak: true, + }); + expect(result.streakCount).toBe(1); + expect(result.hasSolvedToday).toBe(true); + }); + + it('should increment streak for consecutive day', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const existingStreak = { + id: 1, + userId, + lastActiveDate: yesterday, + streakCount: 3, + longestStreak: 3, + lastMilestoneReached: null, + }; + + mockStreakRepository.findOne.mockResolvedValue(existingStreak); + mockStreakRepository.save.mockResolvedValue({ + ...existingStreak, + lastActiveDate: today, + streakCount: 4, + longestStreak: 4, + }); + + const result = await service.updateStreak(userId); + + expect(mockStreakRepository.save).toHaveBeenCalledWith({ + ...existingStreak, + lastActiveDate: today, + streakCount: 4, + longestStreak: 4, + }); + expect(result.streakCount).toBe(4); + expect(result.hasSolvedToday).toBe(true); + }); + + it('should reset streak when day is skipped', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + + const existingStreak = { + id: 1, + userId, + lastActiveDate: twoDaysAgo, + streakCount: 5, + longestStreak: 5, + lastMilestoneReached: null, + }; + + mockStreakRepository.findOne.mockResolvedValue(existingStreak); + mockStreakRepository.save.mockResolvedValue({ + ...existingStreak, + lastActiveDate: today, + streakCount: 1, + longestStreak: 5, // Should remain the same + }); + + const result = await service.updateStreak(userId); + + expect(mockStreakRepository.save).toHaveBeenCalledWith({ + ...existingStreak, + lastActiveDate: today, + streakCount: 1, + longestStreak: 5, + }); + expect(result.streakCount).toBe(1); + expect(result.hasSolvedToday).toBe(true); + }); + + it('should not update streak if already solved today', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const existingStreak = { + id: 1, + userId, + lastActiveDate: today, + streakCount: 3, + longestStreak: 3, + lastMilestoneReached: null, + }; + + mockStreakRepository.findOne.mockResolvedValue(existingStreak); + + const result = await service.updateStreak(userId); + + expect(mockStreakRepository.save).not.toHaveBeenCalled(); + expect(mockEventEmitter.emit).not.toHaveBeenCalled(); + expect(result.streakCount).toBe(3); + expect(result.hasSolvedToday).toBe(true); + }); + + it('should award milestone rewards when reaching milestones', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const existingStreak = { + id: 1, + userId, + lastActiveDate: yesterday, + streakCount: 2, + longestStreak: 2, + lastMilestoneReached: null, + }; + + mockStreakRepository.findOne.mockResolvedValue(existingStreak); + mockStreakRepository.save.mockResolvedValue({ + ...existingStreak, + lastActiveDate: today, + streakCount: 3, + longestStreak: 3, + lastMilestoneReached: 3, + }); + + const result = await service.updateStreak(userId); + + expect(mockEventEmitter.emit).toHaveBeenCalledWith('streak.milestone.reached', { + userId, + milestone: 3, + reward: { + userId, + bonusXp: STREAK_MILESTONES[3].xp, + bonusTokens: STREAK_MILESTONES[3].tokens, + reason: STREAK_MILESTONES[3].description, + }, + }); + expect(result.streakCount).toBe(3); + }); + }); + + describe('getStreak', () => { + it('should return streak data for existing user', async () => { + const userId = 1; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const existingStreak = { + id: 1, + userId, + lastActiveDate: today, + streakCount: 5, + longestStreak: 10, + lastMilestoneReached: 3, + }; + + mockStreakRepository.findOne.mockResolvedValue(existingStreak); + + const result = await service.getStreak(userId); + + expect(result.streakCount).toBe(5); + expect(result.longestStreak).toBe(10); + expect(result.hasSolvedToday).toBe(true); + expect(result.nextMilestone).toBe(7); + expect(result.daysUntilNextMilestone).toBe(2); + }); + + it('should return default data for new user', async () => { + const userId = 1; + + mockStreakRepository.findOne.mockResolvedValue(null); + + const result = await service.getStreak(userId); + + expect(result.streakCount).toBe(0); + expect(result.longestStreak).toBe(0); + expect(result.lastActiveDate).toBeNull(); + expect(result.hasSolvedToday).toBe(false); + expect(result.nextMilestone).toBe(3); + expect(result.daysUntilNextMilestone).toBe(3); + }); + }); + + describe('getStreakLeaderboard', () => { + it('should return leaderboard with pagination', async () => { + const query = { page: 1, limit: 10 }; + const mockEntries = [ + { + userId: 1, + streakCount: 10, + longestStreak: 15, + lastActiveDate: new Date(), + user: { username: 'user1' }, + }, + { + userId: 2, + streakCount: 8, + longestStreak: 12, + lastActiveDate: new Date(), + user: { username: 'user2' }, + }, + ]; + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([mockEntries, 2]), + }; + + mockStreakRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getStreakLeaderboard(query); + + expect(result.entries).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.entries[0].userId).toBe(1); + expect(result.entries[0].username).toBe('user1'); + }); + }); + + describe('getStreakStats', () => { + it('should return streak statistics', async () => { + mockUserRepository.count.mockResolvedValue(100); + mockStreakRepository.count.mockResolvedValue(50); + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ average: 5.5 }), + }; + + const mockMaxQueryBuilder = { + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: 30 }), + }; + + mockStreakRepository.createQueryBuilder + .mockReturnValueOnce(mockQueryBuilder) + .mockReturnValueOnce(mockMaxQueryBuilder); + + const result = await service.getStreakStats(); + + expect(result.totalUsers).toBe(100); + expect(result.activeUsers).toBe(50); + expect(result.averageStreak).toBe(6); + expect(result.topStreak).toBe(30); + }); + }); +}); \ No newline at end of file diff --git a/src/gamification/providers/daily-streak.service.ts b/src/gamification/providers/daily-streak.service.ts new file mode 100644 index 0000000..0cd8ea9 --- /dev/null +++ b/src/gamification/providers/daily-streak.service.ts @@ -0,0 +1,244 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { User } from '../../users/user.entity'; +import { StreakResponseDto, StreakLeaderboardEntryDto, StreakLeaderboardResponseDto, StreakQueryDto } from '../dto/streak.dto'; +import { STREAK_MILESTONES, STREAK_EVENTS, STREAK_CONFIG } from '../constants/streak.constants'; +import { BonusRewardDto } from '../dto/bonus-reward.dto'; + +@Injectable() +export class DailyStreakService { + private readonly logger = new Logger(DailyStreakService.name); + + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Update user's streak after solving a puzzle + */ + async updateStreak(userId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Find or create streak record + let streak = await this.streakRepository.findOne({ + where: { userId }, + relations: ['user'], + }); + + if (!streak) { + // Create new streak record + streak = this.streakRepository.create({ + userId, + lastActiveDate: today, + streakCount: 1, + longestStreak: 1, + lastMilestoneReached: null as number | null, + }); + } else { + const lastActive = new Date(streak.lastActiveDate); + lastActive.setHours(0, 0, 0, 0); + + const daysDifference = Math.floor((today.getTime() - lastActive.getTime()) / (1000 * 60 * 60 * 24)); + + if (daysDifference === 0) { + // User already solved a puzzle today, return current streak + return this.buildStreakResponse(streak); + } else if (daysDifference === 1) { + // Consecutive day, increment streak + streak.streakCount += 1; + streak.lastActiveDate = today; + } else { + // Streak broken, reset to 1 + streak.streakCount = 1; + streak.lastActiveDate = today; + } + + // Update longest streak if current streak is longer + if (streak.streakCount > streak.longestStreak) { + streak.longestStreak = streak.streakCount; + } + } + + // Save the streak + const savedStreak = await this.streakRepository.save(streak); + + // Check for milestones + await this.checkAndAwardMilestones(savedStreak); + + // Emit puzzle solved event + this.eventEmitter.emit(STREAK_EVENTS.PUZZLE_SOLVED, { + userId, + streakCount: savedStreak.streakCount, + isNewStreak: !streak.id, + }); + + this.logger.log(`Updated streak for user ${userId}: ${savedStreak.streakCount} days`); + + return this.buildStreakResponse(savedStreak); + } + + /** + * Get current streak status for a user + */ + async getStreak(userId: string): Promise { + const streak = await this.streakRepository.findOne({ + where: { userId }, + relations: ['user'], + }); + + if (!streak) { + // Return default streak response for new users + return { + streakCount: 0, + longestStreak: 0, + lastActiveDate: null as Date | null, + hasSolvedToday: false, + nextMilestone: 3, + daysUntilNextMilestone: 3, + }; + } + + return this.buildStreakResponse(streak); + } + + /** + * Get streak leaderboard + */ + async getStreakLeaderboard(query: StreakQueryDto): Promise { + const { page = 1, limit = 10 } = query; + const skip = (page - 1) * limit; + + const [entries, total] = await this.streakRepository + .createQueryBuilder('streak') + .leftJoinAndSelect('streak.user', 'user') + .select([ + 'streak.userId', + 'streak.streakCount', + 'streak.longestStreak', + 'streak.lastActiveDate', + 'user.username', + ]) + .orderBy('streak.streakCount', 'DESC') + .addOrderBy('streak.longestStreak', 'DESC') + .addOrderBy('streak.lastActiveDate', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + const leaderboardEntries: StreakLeaderboardEntryDto[] = entries.map(entry => ({ + userId: entry.userId, + username: entry.user?.username || `User ${entry.userId}`, + streakCount: entry.streakCount, + longestStreak: entry.longestStreak, + lastActiveDate: entry.lastActiveDate, + })); + + return { + entries: leaderboardEntries, + total, + page, + limit, + }; + } + + /** + * Check and award milestones + */ + private async checkAndAwardMilestones(streak: DailyStreak): Promise { + const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); + + for (const milestone of milestones) { + if (streak.streakCount >= milestone && + (!streak.lastMilestoneReached || streak.lastMilestoneReached < milestone)) { + + const milestoneConfig = STREAK_MILESTONES[milestone as keyof typeof STREAK_MILESTONES]; + + // Award bonus rewards + const bonusReward: BonusRewardDto = { + userId: streak.userId, + bonusXp: milestoneConfig.xp, + bonusTokens: milestoneConfig.tokens, + reason: milestoneConfig.description, + }; + + this.eventEmitter.emit(STREAK_EVENTS.MILESTONE_REACHED, { + userId: streak.userId, + milestone, + reward: bonusReward, + }); + + // Update last milestone reached + streak.lastMilestoneReached = milestone; + await this.streakRepository.save(streak); + + this.logger.log(`User ${streak.userId} reached ${milestone}-day streak milestone`); + break; // Only award the highest milestone reached + } + } + } + + /** + * Build streak response DTO + */ + private buildStreakResponse(streak: DailyStreak): StreakResponseDto { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const lastActive = new Date(streak.lastActiveDate); + lastActive.setHours(0, 0, 0, 0); + + const hasSolvedToday = today.getTime() === lastActive.getTime(); + + // Find next milestone + const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); + const nextMilestone = milestones.find(m => m > streak.streakCount); + const daysUntilNextMilestone = nextMilestone ? nextMilestone - streak.streakCount : null; + + return { + streakCount: streak.streakCount, + longestStreak: streak.longestStreak, + lastActiveDate: streak.lastActiveDate, + hasSolvedToday, + nextMilestone, + daysUntilNextMilestone: daysUntilNextMilestone || undefined, + }; + } + + /** + * Get streak statistics for admin purposes + */ + async getStreakStats(): Promise<{ + totalUsers: number; + activeUsers: number; + averageStreak: number; + topStreak: number; + }> { + const totalUsers = await this.userRepository.count(); + const activeUsers = await this.streakRepository.count(); + + const avgResult = await this.streakRepository + .createQueryBuilder('streak') + .select('AVG(streak.streakCount)', 'average') + .getRawOne(); + + const topResult = await this.streakRepository + .createQueryBuilder('streak') + .select('MAX(streak.streakCount)', 'max') + .getRawOne(); + + return { + totalUsers, + activeUsers, + averageStreak: Math.round(avgResult?.average || 0), + topStreak: topResult?.max || 0, + }; + } +} \ No newline at end of file diff --git a/src/iq-assessment/providers/iq-assessment.service.ts b/src/iq-assessment/providers/iq-assessment.service.ts index a13fcd6..0ee41f6 100644 --- a/src/iq-assessment/providers/iq-assessment.service.ts +++ b/src/iq-assessment/providers/iq-assessment.service.ts @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { IQAssessmentSession } from '../entities/iq-assessment-session.entity'; import { IQQuestion, @@ -47,6 +48,7 @@ export class IQAssessmentService { @InjectRepository(User) private readonly userRepository: Repository, private readonly httpService: HttpService, private readonly iqAttemptService: IqAttemptService, + private readonly eventEmitter: EventEmitter2, ) {} public async fetchExternalQuestions(amount: number) { @@ -446,6 +448,14 @@ export class IQAssessmentService { // Determine if answer is correct const isCorrect = submitAnswerDto.selectedAnswer === question.correctAnswer; + // Emit event for streak tracking + if (userId) { + this.eventEmitter.emit('iq.question.answered', { + userId: userId, + isCorrect, + }); + } + // Log the attempt using IqAttemptService try { const createAttemptDto: CreateAttemptDto = { diff --git a/src/puzzle/entities/puzzle-progress.entity.ts b/src/puzzle/entities/puzzle-progress.entity.ts index a18de93..b6bc11d 100644 --- a/src/puzzle/entities/puzzle-progress.entity.ts +++ b/src/puzzle/entities/puzzle-progress.entity.ts @@ -1,35 +1,32 @@ -// import { -// Entity, -// PrimaryGeneratedColumn, -// ManyToOne, -// Column, -// } from 'typeorm'; -// import { PuzzleType } from '../enums/puzzle-type.enum'; -// import { ApiProperty } from '@nestjs/swagger'; -// import { User } from 'src/users/user.entity'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { User } from 'src/users/user.entity'; +import { PuzzleType } from '../enums/puzzle-type.enum'; -// @Entity() -// export class PuzzleProgress { -// @PrimaryGeneratedColumn() -// @ApiProperty() -// id: number; +@Entity('puzzle_progress') +export class PuzzleProgress { + @PrimaryGeneratedColumn() + @ApiProperty() + id: number; -// @ManyToOne(() => User, (user) => user.puzzleProgress) -// @ApiProperty({ type: () => User }) -// user: User; + @Column({ name: 'user_id' }) + @ApiProperty() + userId: string; -// @Column({ -// type: 'enum', -// enum: PuzzleType, -// }) -// @ApiProperty({ enum: PuzzleType }) -// puzzleType: PuzzleType; + @ManyToOne(() => User, { eager: true }) + @JoinColumn({ name: 'user_id' }) + @ApiProperty({ type: () => User }) + user: User; -// @Column('int', { default: 0 }) -// @ApiProperty() -// completedCount: number; + @Column({ name: 'puzzle_type', type: 'enum', enum: PuzzleType }) + @ApiProperty({ enum: PuzzleType }) + puzzleType: PuzzleType; -// @Column('int', { default: 0 }) -// @ApiProperty() -// total: number; -// } + @Column({ name: 'completed_count', default: 0 }) + @ApiProperty({ description: 'Number of puzzles completed of this type' }) + completedCount: number; + + @Column({ default: 0 }) + @ApiProperty({ description: 'Total puzzles available of this type' }) + total: number; +} diff --git a/src/puzzle/entities/puzzle-submission.entity.ts b/src/puzzle/entities/puzzle-submission.entity.ts index f06cfbd..92be7fe 100644 --- a/src/puzzle/entities/puzzle-submission.entity.ts +++ b/src/puzzle/entities/puzzle-submission.entity.ts @@ -1,43 +1,53 @@ -// import { -// Entity, -// PrimaryGeneratedColumn, -// ManyToOne, -// Column, -// CreateDateColumn, -// } from 'typeorm'; -// import { Puzzle } from './puzzle.entity'; -// import { ApiProperty } from '@nestjs/swagger'; -// import { User } from 'src/users/user.entity'; - -// @Entity() -// export class PuzzleSubmission { -// @PrimaryGeneratedColumn() -// @ApiProperty() -// id: number; - -// @ManyToOne(() => Puzzle, { eager: true }) -// @ApiProperty({ type: () => Puzzle }) -// puzzle: Puzzle; - -// @ManyToOne(() => User, (user) => user.puzzleSubmissions, { eager: true }) -// @ApiProperty({ type: () => User }) -// user: User; - -// @Column({ type: 'jsonb' }) -// // @ApiProperty({ type: 'object', description: 'Submission data like code or answers' }) -// @ApiProperty({ -// type: 'object', -// description: 'Submission data like code or answers', -// additionalProperties: true -// }) -// attemptData: Record; - -// @Column() -// @ApiProperty({ description: 'Whether the submission passed or not' }) -// result: boolean; - -// @CreateDateColumn() -// @ApiProperty({ type: String, format: 'date-time' }) -// submittedAt: Date; -// } +import { + Entity, + PrimaryGeneratedColumn, + ManyToOne, + Column, + CreateDateColumn, + JoinColumn, +} from 'typeorm'; +import { Puzzle } from './puzzle.entity'; +import { ApiProperty } from '@nestjs/swagger'; +import { User } from 'src/users/user.entity'; + +@Entity('puzzle_submission') +export class PuzzleSubmission { + @PrimaryGeneratedColumn() + @ApiProperty() + id: number; + + @Column({ name: 'puzzle_id' }) + @ApiProperty() + puzzleId: number; + + @ManyToOne(() => Puzzle, { eager: true }) + @JoinColumn({ name: 'puzzle_id' }) + @ApiProperty({ type: () => Puzzle }) + puzzle: Puzzle; + + @Column({ name: 'user_id' }) + @ApiProperty() + userId: string; + + @ManyToOne(() => User, { eager: true }) + @JoinColumn({ name: 'user_id' }) + @ApiProperty({ type: () => User }) + user: User; + + @Column({ type: 'jsonb' }) + @ApiProperty({ + type: 'object', + description: 'Submission data like code or answers', + additionalProperties: true + }) + attemptData: Record; + + @Column() + @ApiProperty({ description: 'Whether the submission passed or not' }) + result: boolean; + + @CreateDateColumn({ name: 'submitted_at' }) + @ApiProperty({ type: String, format: 'date-time' }) + submittedAt: Date; +} \ No newline at end of file diff --git a/src/puzzle/puzzle.controller.ts b/src/puzzle/puzzle.controller.ts index b0673b3..50528d4 100644 --- a/src/puzzle/puzzle.controller.ts +++ b/src/puzzle/puzzle.controller.ts @@ -11,13 +11,81 @@ import { ValidationPipe, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; import { PuzzleFilterDto, SubmitPuzzleDto, PuzzleProgressDto } from './dto/puzzle.dto'; -import { Auth } from '../auth/decorators/auth.decorator'; import { ActiveUser } from '../auth/decorators/activeUser.decorator'; +import { ActiveUserData } from '../auth/interfaces/activeInterface'; +import { PuzzleService } from './puzzle.service'; +import { Puzzle } from './entities/puzzle.entity'; @ApiTags('Puzzle') @Controller('puzzles') +@UseGuards(AuthGuard('jwt')) @ApiBearerAuth() export class PuzzleController { - constructor() {} + constructor(private readonly puzzleService: PuzzleService) {} + + @Get() + @ApiOperation({ summary: 'Get all puzzles with optional filters' }) + @ApiResponse({ + status: 200, + description: 'List of puzzles', + type: [Puzzle], + }) + @ApiQuery({ name: 'type', required: false, enum: ['logic', 'coding', 'blockchain'] }) + @ApiQuery({ name: 'difficulty', required: false, enum: ['easy', 'medium', 'hard'] }) + @ApiQuery({ name: 'isPublished', required: false, type: Boolean }) + async getPuzzles(@Query() filters: PuzzleFilterDto): Promise { + return this.puzzleService.getPuzzles(filters); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific puzzle by ID' }) + @ApiResponse({ + status: 200, + description: 'Puzzle details', + type: Puzzle, + }) + @ApiResponse({ status: 404, description: 'Puzzle not found' }) + @ApiParam({ name: 'id', description: 'Puzzle ID' }) + async getPuzzle(@Param('id', ParseIntPipe) id: number): Promise { + return this.puzzleService.getPuzzle(id); + } + + @Post(':id/submit') + @ApiOperation({ summary: 'Submit a solution for a puzzle' }) + @ApiResponse({ + status: 200, + description: 'Puzzle submission result', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + xpEarned: { type: 'number' }, + tokensEarned: { type: 'number' }, + message: { type: 'string' }, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Puzzle not found' }) + @ApiParam({ name: 'id', description: 'Puzzle ID' }) + @ApiBody({ type: SubmitPuzzleDto }) + async submitPuzzle( + @ActiveUser() user: ActiveUserData, + @Param('id', ParseIntPipe) puzzleId: number, + @Body(ValidationPipe) submitDto: SubmitPuzzleDto, + ) { + return this.puzzleService.submitPuzzleSolution(user.sub.toString(), puzzleId, submitDto); + } + + @Get('progress/user') + @ApiOperation({ summary: 'Get current user puzzle progress' }) + @ApiResponse({ + status: 200, + description: 'User puzzle progress', + type: [PuzzleProgressDto], + }) + async getUserProgress(@ActiveUser() user: ActiveUserData) { + return this.puzzleService.getUserProgress(user.sub.toString()); + } } \ No newline at end of file diff --git a/src/puzzle/puzzle.module.ts b/src/puzzle/puzzle.module.ts index 5528800..25db0a7 100644 --- a/src/puzzle/puzzle.module.ts +++ b/src/puzzle/puzzle.module.ts @@ -1,17 +1,21 @@ // src/puzzle/puzzle.module.ts import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { PuzzleController } from './puzzle.controller'; +import { PuzzleService } from './puzzle.service'; import { GamificationModule } from 'src/gamification/gamification.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { Puzzle } from './entities/puzzle.entity'; +import { PuzzleSubmission } from './entities/puzzle-submission.entity'; +import { PuzzleProgress } from './entities/puzzle-progress.entity'; +import { User } from '../users/user.entity'; @Module({ imports: [ forwardRef(() => GamificationModule), - TypeOrmModule.forFeature([Puzzle]), + TypeOrmModule.forFeature([Puzzle, PuzzleSubmission, PuzzleProgress, User]), ], controllers: [PuzzleController], - providers: [], - exports: [], + providers: [PuzzleService], + exports: [PuzzleService], }) export class PuzzleModule {} diff --git a/src/puzzle/puzzle.service.spec.ts b/src/puzzle/puzzle.service.spec.ts new file mode 100644 index 0000000..e72a054 --- /dev/null +++ b/src/puzzle/puzzle.service.spec.ts @@ -0,0 +1,346 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotFoundException } from '@nestjs/common'; +import { PuzzleService } from './puzzle.service'; +import { Puzzle } from './entities/puzzle.entity'; +import { PuzzleSubmission } from './entities/puzzle-submission.entity'; +import { PuzzleProgress } from './entities/puzzle-progress.entity'; +import { User } from '../users/user.entity'; +import { SubmitPuzzleDto } from './dto/puzzle.dto'; + +describe('PuzzleService', () => { + let service: PuzzleService; + let puzzleRepository: Repository; + let submissionRepository: Repository; + let progressRepository: Repository; + let userRepository: Repository; + let eventEmitter: EventEmitter2; + + const mockPuzzleRepository = { + findOne: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + })), + }; + + const mockSubmissionRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }; + + const mockProgressRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockEventEmitter = { + emit: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PuzzleService, + { + provide: getRepositoryToken(Puzzle), + useValue: mockPuzzleRepository, + }, + { + provide: getRepositoryToken(PuzzleSubmission), + useValue: mockSubmissionRepository, + }, + { + provide: getRepositoryToken(PuzzleProgress), + useValue: mockProgressRepository, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + ], + }).compile(); + + service = module.get(PuzzleService); + puzzleRepository = module.get>(getRepositoryToken(Puzzle)); + submissionRepository = module.get>(getRepositoryToken(PuzzleSubmission)); + progressRepository = module.get>(getRepositoryToken(PuzzleProgress)); + userRepository = module.get>(getRepositoryToken(User)); + eventEmitter = module.get(EventEmitter2); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('submitPuzzleSolution', () => { + const userId = 1; + const puzzleId = 101; + const submitDto: SubmitPuzzleDto = { solution: 'correct answer' }; + + it('should successfully submit correct puzzle solution', async () => { + const puzzle = { + id: puzzleId, + title: 'Test Puzzle', + description: 'Test Description', + type: 'logic', + difficulty: 'easy', + solution: 'correct answer', + isPublished: true, + }; + + const user = { + id: userId, + xp: 100, + level: 1, + }; + + const submission = { + id: 1, + userId, + puzzleId, + attemptData: { solution: submitDto.solution }, + result: true, + submittedAt: new Date(), + }; + + mockPuzzleRepository.findOne.mockResolvedValue(puzzle); + mockSubmissionRepository.create.mockReturnValue(submission); + mockSubmissionRepository.save.mockResolvedValue(submission); + mockSubmissionRepository.findOne.mockResolvedValue(null); // No previous success + mockProgressRepository.findOne.mockResolvedValue(null); + mockProgressRepository.create.mockReturnValue({ + userId, + puzzleType: puzzle.type, + completedCount: 0, + }); + mockProgressRepository.save.mockResolvedValue({ + userId, + puzzleType: puzzle.type, + completedCount: 1, + }); + mockUserRepository.findOne.mockResolvedValue(user); + mockUserRepository.save.mockResolvedValue({ + ...user, + xp: 200, + level: 1, + }); + + const result = await service.submitPuzzleSolution(userId, puzzleId, submitDto); + + expect(mockPuzzleRepository.findOne).toHaveBeenCalledWith({ + where: { id: puzzleId }, + }); + expect(mockSubmissionRepository.create).toHaveBeenCalledWith({ + userId, + puzzleId, + attemptData: { solution: submitDto.solution }, + result: true, + submittedAt: expect.any(Date), + }); + expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { + userId, + puzzleId, + isCorrect: true, + timestamp: expect.any(Date), + }); + expect(result.success).toBe(true); + expect(result.xpEarned).toBe(100); + expect(result.tokensEarned).toBe(10); + }); + + it('should handle incorrect puzzle solution', async () => { + const puzzle = { + id: puzzleId, + title: 'Test Puzzle', + description: 'Test Description', + type: 'logic', + difficulty: 'easy', + solution: 'correct answer', + isPublished: true, + }; + + const submitDtoIncorrect: SubmitPuzzleDto = { solution: 'wrong answer' }; + + mockPuzzleRepository.findOne.mockResolvedValue(puzzle); + mockSubmissionRepository.create.mockReturnValue({ + id: 1, + userId, + puzzleId, + attemptData: { solution: submitDtoIncorrect.solution }, + result: false, + submittedAt: new Date(), + }); + mockSubmissionRepository.save.mockResolvedValue({ + id: 1, + userId, + puzzleId, + attemptData: { solution: submitDtoIncorrect.solution }, + result: false, + submittedAt: new Date(), + }); + + const result = await service.submitPuzzleSolution(userId, puzzleId, submitDtoIncorrect); + + expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { + userId, + puzzleId, + isCorrect: false, + timestamp: expect.any(Date), + }); + expect(result.success).toBe(false); + expect(result.message).toBe('Incorrect solution. Try again!'); + }); + + it('should throw NotFoundException when puzzle not found', async () => { + mockPuzzleRepository.findOne.mockResolvedValue(null); + + await expect(service.submitPuzzleSolution(userId, puzzleId, submitDto)) + .rejects.toThrow(NotFoundException); + }); + + it('should handle already solved puzzle', async () => { + const puzzle = { + id: puzzleId, + title: 'Test Puzzle', + description: 'Test Description', + type: 'logic', + difficulty: 'easy', + solution: 'correct answer', + isPublished: true, + }; + + const submission = { + id: 1, + userId, + puzzleId, + attemptData: { solution: submitDto.solution }, + result: true, + submittedAt: new Date(), + }; + + const previousSuccess = { + id: 2, + userId, + puzzleId, + result: true, + }; + + mockPuzzleRepository.findOne.mockResolvedValue(puzzle); + mockSubmissionRepository.create.mockReturnValue(submission); + mockSubmissionRepository.save.mockResolvedValue(submission); + mockSubmissionRepository.findOne.mockResolvedValue(previousSuccess); + + const result = await service.submitPuzzleSolution(userId, puzzleId, submitDto); + + expect(result.success).toBe(true); + expect(result.xpEarned).toBe(0); + expect(result.tokensEarned).toBe(0); + expect(result.message).toBe('Puzzle already solved!'); + }); + }); + + describe('getPuzzle', () => { + it('should return puzzle when found', async () => { + const puzzle = { + id: 101, + title: 'Test Puzzle', + description: 'Test Description', + type: 'logic', + difficulty: 'easy', + solution: 'correct answer', + isPublished: true, + }; + + mockPuzzleRepository.findOne.mockResolvedValue(puzzle); + + const result = await service.getPuzzle(101); + + expect(result).toEqual(puzzle); + }); + + it('should throw NotFoundException when puzzle not found', async () => { + mockPuzzleRepository.findOne.mockResolvedValue(null); + + await expect(service.getPuzzle(999)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getPuzzles', () => { + it('should return filtered puzzles', async () => { + const puzzles = [ + { + id: 1, + title: 'Logic Puzzle 1', + type: 'logic', + difficulty: 'easy', + isPublished: true, + }, + { + id: 2, + title: 'Logic Puzzle 2', + type: 'logic', + difficulty: 'medium', + isPublished: true, + }, + ]; + + const mockQueryBuilder = { + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(puzzles), + }; + + mockPuzzleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const filters = { type: 'logic', difficulty: 'easy' }; + const result = await service.getPuzzles(filters); + + expect(result).toEqual(puzzles); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(2); + }); + }); + + describe('getUserProgress', () => { + it('should return user puzzle progress', async () => { + const progress = [ + { + id: 1, + userId: 1, + puzzleType: 'logic', + completedCount: 5, + total: 10, + }, + { + id: 2, + userId: 1, + puzzleType: 'coding', + completedCount: 3, + total: 8, + }, + ]; + + mockProgressRepository.find.mockResolvedValue(progress); + + const result = await service.getUserProgress(1); + + expect(result).toEqual(progress); + expect(mockProgressRepository.find).toHaveBeenCalledWith({ + where: { userId: 1 }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/puzzle/puzzle.service.ts b/src/puzzle/puzzle.service.ts index e69de29..a4c05af 100644 --- a/src/puzzle/puzzle.service.ts +++ b/src/puzzle/puzzle.service.ts @@ -0,0 +1,199 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Puzzle } from './entities/puzzle.entity'; +import { PuzzleSubmission } from './entities/puzzle-submission.entity'; +import { PuzzleProgress } from './entities/puzzle-progress.entity'; +import { User } from '../users/user.entity'; +import { SubmitPuzzleDto } from './dto/puzzle.dto'; +import { PuzzleSubmissionDto } from '../gamification/dto/puzzle-submission.dto'; +import { PuzzleType } from './enums/puzzle-type.enum'; + +@Injectable() +export class PuzzleService { + private readonly logger = new Logger(PuzzleService.name); + + constructor( + @InjectRepository(Puzzle) + private readonly puzzleRepository: Repository, + @InjectRepository(PuzzleSubmission) + private readonly submissionRepository: Repository, + @InjectRepository(PuzzleProgress) + private readonly progressRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + async submitPuzzleSolution( + userId: string, + puzzleId: number, + submitDto: SubmitPuzzleDto, + ): Promise<{ success: boolean; xpEarned?: number; tokensEarned?: number; message?: string }> { + try { + // 1. Get the puzzle and verify it exists + const puzzle = await this.puzzleRepository.findOne({ + where: { id: puzzleId }, + }); + + if (!puzzle) { + throw new NotFoundException('Puzzle not found'); + } + + // 2. Verify the solution + const isCorrect = this.verifySolution(puzzle, submitDto.solution); + + // 3. Record the submission + const submission = this.submissionRepository.create({ + userId, + puzzleId, + attemptData: { solution: submitDto.solution }, + result: isCorrect, + submittedAt: new Date(), + }); + await this.submissionRepository.save(submission); + + // 4. Emit puzzle submission event for streak tracking + const puzzleSubmissionEvent: PuzzleSubmissionDto = { + userId, + puzzleId, + isCorrect, + timestamp: new Date(), + }; + + this.eventEmitter.emit('puzzle.submitted', puzzleSubmissionEvent); + + if (!isCorrect) { + return { + success: false, + message: 'Incorrect solution. Try again!' + }; + } + + // 5. Check for previous successful submissions (idempotency) + const previousSuccess = await this.submissionRepository.findOne({ + where: { userId, puzzleId, result: true }, + }); + + if (previousSuccess && previousSuccess.id !== submission.id) { + return { + success: true, + xpEarned: 0, + tokensEarned: 0, + message: 'Puzzle already solved!' + }; + } + + // 6. Update puzzle progress + await this.updatePuzzleProgress(userId, puzzle.type); + + // 7. Award XP and tokens + const { xpEarned, tokensEarned } = this.calculateRewards(puzzle.difficulty); + await this.updateUserStats(userId, xpEarned, tokensEarned); + + this.logger.log(`User ${userId} successfully solved puzzle ${puzzleId}`); + + return { + success: true, + xpEarned, + tokensEarned, + message: 'Puzzle solved successfully!' + }; + } catch (error) { + this.logger.error(`Error submitting puzzle solution: ${error.message}`, error.stack); + throw error; + } + } + + private verifySolution(puzzle: Puzzle, submittedSolution: string): boolean { + // Simple string comparison for now + // In a real implementation, this could involve more complex verification logic + return puzzle.solution.toLowerCase().trim() === submittedSolution.toLowerCase().trim(); + } + + private async updatePuzzleProgress( + userId: string, + puzzleType: PuzzleType, + ): Promise { + let progress = await this.progressRepository.findOne({ + where: { userId, puzzleType: puzzleType as PuzzleType }, + }); + + if (!progress) { + progress = this.progressRepository.create({ + userId, + puzzleType: puzzleType as PuzzleType, + completedCount: 0, + }); + } + + progress.completedCount += 1; + await this.progressRepository.save(progress); + } + + private calculateRewards(difficulty: string): { xpEarned: number; tokensEarned: number } { + const rewards = { + easy: { xp: 100, tokens: 10 }, + medium: { xp: 250, tokens: 25 }, + hard: { xp: 500, tokens: 50 }, + }; + + return { + xpEarned: rewards[difficulty]?.xp || 100, + tokensEarned: rewards[difficulty]?.tokens || 10, + }; + } + + private async updateUserStats( + userId: string, + xpEarned: number, + tokensEarned: number, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + user.xp += xpEarned; + user.level = Math.floor(user.xp / 1000) + 1; // Simple level calculation + + await this.userRepository.save(user); + } + + async getPuzzle(puzzleId: number): Promise { + const puzzle = await this.puzzleRepository.findOne({ + where: { id: puzzleId }, + }); + + if (!puzzle) { + throw new NotFoundException('Puzzle not found'); + } + + return puzzle; + } + + async getPuzzles(filters?: any): Promise { + const queryBuilder = this.puzzleRepository.createQueryBuilder('puzzle'); + + if (filters?.type) { + queryBuilder.andWhere('puzzle.type = :type', { type: filters.type }); + } + + if (filters?.difficulty) { + queryBuilder.andWhere('puzzle.difficulty = :difficulty', { difficulty: filters.difficulty }); + } + + if (filters?.isPublished !== undefined) { + queryBuilder.andWhere('puzzle.isPublished = :isPublished', { isPublished: filters.isPublished }); + } + + return queryBuilder.getMany(); + } + + async getUserProgress(userId: string): Promise { + return this.progressRepository.find({ + where: { userId }, + }); + } +}