From cfe9d29ec746180582cb707df3a948a210671a32 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sun, 6 Jul 2025 22:11:09 +0100 Subject: [PATCH] feat: link badges to users and assign on achievement unlock --- src/PuzzleService Logic/constants.ts | 13 -- .../puzzle-progress.entity.ts | 17 -- .../puzzle-submission.entity.ts | 22 -- src/PuzzleService Logic/puzzle.controller.ts | 27 --- src/PuzzleService Logic/puzzle.entity.ts | 31 --- src/PuzzleService Logic/puzzle.module.ts | 25 -- src/PuzzleService Logic/puzzle.service.ts | 147 ------------ src/PuzzleService Logic/user.entity.ts | 19 -- src/achievement/achievement.controller.ts | 27 +++ src/achievement/achievement.module.ts | 18 ++ .../entities/achievement.entity.ts | 19 ++ .../entities/user-achievement.entity.ts | 18 ++ .../providers/achievement-unlocker.service.ts | 67 ++++++ .../providers/achievement.service.ts | 14 ++ .../seeds/initial-achievements.seed.ts | 12 + src/app.module.ts | 4 +- src/auth/interfaces/activeInterface.ts | 2 +- src/badge/entities/badge.entity.ts | 47 ++-- .../controllers/iq-assessment.controller.ts | 218 ++++++++++++------ src/iq-assessment/iq-assessment.module.ts | 2 + .../providers/iq-assessment.service.ts | 25 +- src/users/user.entity.ts | 25 +- 22 files changed, 397 insertions(+), 402 deletions(-) delete mode 100644 src/PuzzleService Logic/constants.ts delete mode 100644 src/PuzzleService Logic/puzzle-progress.entity.ts delete mode 100644 src/PuzzleService Logic/puzzle-submission.entity.ts delete mode 100644 src/PuzzleService Logic/puzzle.controller.ts delete mode 100644 src/PuzzleService Logic/puzzle.entity.ts delete mode 100644 src/PuzzleService Logic/puzzle.module.ts delete mode 100644 src/PuzzleService Logic/puzzle.service.ts delete mode 100644 src/PuzzleService Logic/user.entity.ts create mode 100644 src/achievement/achievement.controller.ts create mode 100644 src/achievement/achievement.module.ts create mode 100644 src/achievement/entities/achievement.entity.ts create mode 100644 src/achievement/entities/user-achievement.entity.ts create mode 100644 src/achievement/providers/achievement-unlocker.service.ts create mode 100644 src/achievement/providers/achievement.service.ts create mode 100644 src/achievement/seeds/initial-achievements.seed.ts diff --git a/src/PuzzleService Logic/constants.ts b/src/PuzzleService Logic/constants.ts deleted file mode 100644 index ff17594..0000000 --- a/src/PuzzleService Logic/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const XP_BY_DIFFICULTY = { - easy: 100, - medium: 250, - hard: 500, -}; - -export const TOKENS_BY_DIFFICULTY = { - easy: 10, - medium: 25, - hard: 50, -}; - -export const XP_PER_LEVEL = 1000; \ No newline at end of file diff --git a/src/PuzzleService Logic/puzzle-progress.entity.ts b/src/PuzzleService Logic/puzzle-progress.entity.ts deleted file mode 100644 index ff7e839..0000000 --- a/src/PuzzleService Logic/puzzle-progress.entity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; -import { PuzzleType } from './puzzle.entity'; - -@Entity() -export class PuzzleProgress { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - userId: string; - - @Column({ type: 'enum', enum: PuzzleType }) - puzzleType: PuzzleType; - - @Column({ default: 0 }) - completedCount: number; -} \ No newline at end of file diff --git a/src/PuzzleService Logic/puzzle-submission.entity.ts b/src/PuzzleService Logic/puzzle-submission.entity.ts deleted file mode 100644 index 8cf62f7..0000000 --- a/src/PuzzleService Logic/puzzle-submission.entity.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; - -@Entity() -export class PuzzleSubmission { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - userId: string; - - @Column() - puzzleId: string; - - @Column({ type: 'jsonb' }) - attemptData: any; - - @Column() - result: boolean; - - @Column() - submittedAt: Date; -} \ No newline at end of file diff --git a/src/PuzzleService Logic/puzzle.controller.ts b/src/PuzzleService Logic/puzzle.controller.ts deleted file mode 100644 index d6e17a3..0000000 --- a/src/PuzzleService Logic/puzzle.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Post, Param, Body, UseGuards } from '@nestjs/common'; -import { PuzzleService } from './puzzle.service'; -import { AuthGuard } from '@nestjs/passport'; -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -@Controller('puzzles') -@UseGuards(AuthGuard) -export class PuzzleController { - constructor(private readonly puzzleService: PuzzleService) {} - - @Post(':id/submit') - async submitPuzzle( - @UserId() userId: string, - @Param('id') puzzleId: string, - @Body() attemptData: any, - ) { - return this.puzzleService.submitPuzzleSolution(userId, puzzleId, attemptData); - } -}; - -export const UserId = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - // Assumes user object is attached to request by AuthGuard - return request.user?.id; - }, -); diff --git a/src/PuzzleService Logic/puzzle.entity.ts b/src/PuzzleService Logic/puzzle.entity.ts deleted file mode 100644 index 43d2fc3..0000000 --- a/src/PuzzleService Logic/puzzle.entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; - -export enum PuzzleType { - LOGIC = 'logic', - CODING = 'coding', - BLOCKCHAIN = 'blockchain', -} - -export enum PuzzleDifficulty { - EASY = 'easy', - MEDIUM = 'medium', - HARD = 'hard', -} - -@Entity() -export class Puzzle { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - title: string; - - @Column({ type: 'jsonb' }) - solution: any; - - @Column({ type: 'enum', enum: PuzzleType }) - type: PuzzleType; - - @Column({ type: 'enum', enum: PuzzleDifficulty }) - difficulty: PuzzleDifficulty; -} \ No newline at end of file diff --git a/src/PuzzleService Logic/puzzle.module.ts b/src/PuzzleService Logic/puzzle.module.ts deleted file mode 100644 index c011ce3..0000000 --- a/src/PuzzleService Logic/puzzle.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -// import { Module } from '@nestjs/common'; -// import { TypeOrmModule } from '@nestjs/typeorm'; -// import { PuzzleService } from './puzzle.service'; -// import { PuzzleController } from './puzzle.controller'; -// import { -// Puzzle, -// PuzzleSubmission, -// PuzzleProgress, -// User, -// } from './entities'; - -// @Module({ -// imports: [ -// TypeOrmModule.forFeature([ -// Puzzle, -// PuzzleSubmission, -// PuzzleProgress, -// User, -// ]), -// ], -// controllers: [PuzzleController], -// providers: [PuzzleService], -// exports: [PuzzleService], -// }) -// export class PuzzleModule {} \ No newline at end of file diff --git a/src/PuzzleService Logic/puzzle.service.ts b/src/PuzzleService Logic/puzzle.service.ts deleted file mode 100644 index 23a8c22..0000000 --- a/src/PuzzleService Logic/puzzle.service.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Puzzle, PuzzleDifficulty } from './puzzle.entity'; -import { PuzzleSubmission } from './puzzle-submission.entity'; -import { PuzzleProgress } from './puzzle-progress.entity'; -import { User } from './user.entity'; -import { PuzzleType } from 'src/puzzle/enums/puzzle-type.enum'; -import { TOKENS_BY_DIFFICULTY, XP_BY_DIFFICULTY, XP_PER_LEVEL } from './constants'; - -@Injectable() -export class PuzzleService { - 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, - ) {} - - async submitPuzzleSolution( - userId: string, - puzzleId: string, - attemptData: any, - ): Promise<{ success: boolean; xpEarned?: number; tokensEarned?: number }> { - // 1. Get the puzzle and verify it exists - const puzzle = await this.puzzleRepository.findOne({ - where: { id: puzzleId }, - }); - if (!puzzle) { - throw new Error('Puzzle not found'); - } - - // 2. Verify the solution - const isCorrect = this.verifySolution(puzzle, attemptData); - - // 3. Record the submission - const submission = this.submissionRepository.create({ - userId, - puzzleId, - attemptData, - result: isCorrect, - submittedAt: new Date(), - }); - await this.submissionRepository.save(submission); - - if (!isCorrect) { - return { success: false }; - } - - // 4. Check for previous successful submissions (idempotency) - const previousSuccess = await this.submissionRepository.findOne({ - where: { userId, puzzleId, result: true }, - }); - if (previousSuccess) { - return { success: true, xpEarned: 0, tokensEarned: 0 }; - } - - // 5. Update puzzle progress - await this.updatePuzzleProgress(userId, puzzle.type); - - // 6. Award XP and tokens - const { xpEarned, tokensEarned } = this.calculateRewards(puzzle.difficulty); - await this.updateUserStats(userId, xpEarned, tokensEarned); - - return { success: true, xpEarned, tokensEarned }; - } - - private verifySolution(puzzle: Puzzle, attemptData: any): boolean { - switch (puzzle.type) { - case PuzzleType.LOGIC: - return this.verifyLogicPuzzle(puzzle.solution, attemptData); - case PuzzleType.CODING: - return this.verifyCodingPuzzle(puzzle.solution, attemptData); - case PuzzleType.BLOCKCHAIN: - return this.verifyBlockchainPuzzle(puzzle.solution, attemptData); - default: - throw new Error('Unknown puzzle type'); - } - } - - private verifyLogicPuzzle(solution: any, attemptData: any): boolean { - // Simple comparison for logic puzzles - return JSON.stringify(solution) === JSON.stringify(attemptData); - } - - private verifyCodingPuzzle(solution: any, attemptData: any): boolean { - // More complex verification for coding puzzles - // Might involve running test cases against the submitted code - // This is a simplified version - return solution.output === attemptData.output; - } - - private verifyBlockchainPuzzle(solution: any, attemptData: any): boolean { - // Special verification for blockchain puzzles - // Might involve verifying transactions or smart contract interactions - return solution.hash === attemptData.hash; - } - - private async updatePuzzleProgress( - userId: string, - puzzleType: PuzzleType, - ): Promise { - let progress = await this.progressRepository.findOne({ - where: { userId, puzzleType }, - }); - - if (!progress) { - progress = this.progressRepository.create({ - userId, - puzzleType, - completedCount: 0, - }); - } - - progress.completedCount += 1; - await this.progressRepository.save(progress); - } - - private calculateRewards(difficulty: PuzzleDifficulty): { xpEarned: number; tokensEarned: number } { - return { - xpEarned: XP_BY_DIFFICULTY[difficulty as keyof typeof XP_BY_DIFFICULTY], - tokensEarned: TOKENS_BY_DIFFICULTY[difficulty as keyof typeof TOKENS_BY_DIFFICULTY], - }; - } - - private async updateUserStats( - userId: string, - xpEarned: number, - tokensEarned: number, - ): Promise { - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new Error('User not found'); - } - - user.experiencePoints += xpEarned; - user.totalTokensEarned += tokensEarned; - user.level = Math.floor(user.experiencePoints / XP_PER_LEVEL); - user.lastPuzzleSolvedAt = new Date(); - - await this.userRepository.save(user); - } -} \ No newline at end of file diff --git a/src/PuzzleService Logic/user.entity.ts b/src/PuzzleService Logic/user.entity.ts deleted file mode 100644 index 0ee3652..0000000 --- a/src/PuzzleService Logic/user.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; - -@Entity() -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ default: 0 }) - experiencePoints: number; - - @Column({ default: 1 }) - level: number; - - @Column({ default: 0 }) - totalTokensEarned: number; - - @Column({ nullable: true }) - lastPuzzleSolvedAt: Date; -} \ No newline at end of file diff --git a/src/achievement/achievement.controller.ts b/src/achievement/achievement.controller.ts new file mode 100644 index 0000000..094f8ff --- /dev/null +++ b/src/achievement/achievement.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Controller('achievements') +export class AchievementController { + constructor( + @InjectRepository(UserAchievement) + private readonly userAchievementRepo: Repository + ) {} +@Get('/users/:id/achievements') +public async getUserAchievements(@Param('id') userId: string) { + const unlocked = await this.userAchievementRepo.find({ + where: { user: { id: userId } }, + relations: ['achievement'], + }); + + return unlocked.map((ua) => ({ + id: ua.achievement.id, + title: ua.achievement.title, + description: ua.achievement.description, + iconUrl: ua.achievement.iconUrl, + unlockedAt: ua.unlockedAt, + })); +} +} diff --git a/src/achievement/achievement.module.ts b/src/achievement/achievement.module.ts new file mode 100644 index 0000000..34ed361 --- /dev/null +++ b/src/achievement/achievement.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { AchievementController } from './achievement.controller'; +import { AchievementService } from './providers/achievement.service'; +import { AchievementUnlockerProvider } from './providers/achievement-unlocker.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { Achievement } from './entities/achievement.entity'; +import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; +import { Badge } from 'src/badge/entities/badge.entity'; +import { User } from 'src/users/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Achievement, UserAchievement, LeaderboardEntry, Badge, User])], + controllers: [AchievementController], + providers: [AchievementService, AchievementUnlockerProvider], + exports: [AchievementService] +}) +export class AchievementModule {} diff --git a/src/achievement/entities/achievement.entity.ts b/src/achievement/entities/achievement.entity.ts new file mode 100644 index 0000000..62d845d --- /dev/null +++ b/src/achievement/entities/achievement.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity('achievements') +export class Achievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column() + description: string; + + @Column() + iconUrl: string; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/src/achievement/entities/user-achievement.entity.ts b/src/achievement/entities/user-achievement.entity.ts new file mode 100644 index 0000000..fa89dcb --- /dev/null +++ b/src/achievement/entities/user-achievement.entity.ts @@ -0,0 +1,18 @@ +import { CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { Achievement } from "./achievement.entity"; +import { User } from "src/users/user.entity"; + +@Entity('user_achievements') +export class UserAchievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { eager: true }) + user: User; + + @ManyToOne(() => Achievement, { eager: true }) + achievement: Achievement; + + @CreateDateColumn() + unlockedAt: Date; +} \ No newline at end of file diff --git a/src/achievement/providers/achievement-unlocker.service.ts b/src/achievement/providers/achievement-unlocker.service.ts new file mode 100644 index 0000000..c2701ab --- /dev/null +++ b/src/achievement/providers/achievement-unlocker.service.ts @@ -0,0 +1,67 @@ +import { Injectable } 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'; +import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; +import { User } from 'src/users/user.entity'; +import { Badge } from 'src/badge/entities/badge.entity'; + +@Injectable() +export class AchievementUnlockerProvider { + constructor( + @InjectRepository(UserAchievement) + private readonly userAchievementRepo: Repository, + + @InjectRepository(Achievement) + private readonly achievementRepo: Repository, + + @InjectRepository(LeaderboardEntry) + private readonly leaderboardRepo: Repository, + + @InjectRepository(User) + private readonly userRepo: Repository, + + @InjectRepository(Badge) + private readonly badgeRepo: Repository, + ) {} + + async unlockAchievementsForUser(user: User) { + const existing = await this.userAchievementRepo.find({ + where: { user: { id: user.id } }, + }); + + const unlockedIds = new Set(existing.map((ua) => ua.achievement.id)); + + const toCheck = await this.achievementRepo.find(); + + for (const achievement of toCheck) { + if (unlockedIds.has(achievement.id)) continue; + + if (achievement.title === 'Mind Master') { + if (user.puzzlesCompleted >= 50) { + await this.userAchievementRepo.save({ user, achievement }); + } + + const badge = await this.badgeRepo.findOne({ + where: { title: 'Mind Master' }, + }); + if (badge) { + user.badge = badge; + await this.userRepo.save(user); + } + } + + if (achievement.title === 'Top 100') { + const rank = await this.leaderboardRepo + .createQueryBuilder('entry') + .where('entry.tokens > :tokens', { tokens: user.tokens }) + .getCount(); + + if (rank + 1 <= 100) { + await this.userAchievementRepo.save({ user, achievement }); + } + } + } + } +} diff --git a/src/achievement/providers/achievement.service.ts b/src/achievement/providers/achievement.service.ts new file mode 100644 index 0000000..c0b3aa3 --- /dev/null +++ b/src/achievement/providers/achievement.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { AchievementUnlockerProvider } from './achievement-unlocker.service'; +import { User } from 'src/users/user.entity'; + +@Injectable() +export class AchievementService { + constructor( + private readonly achievementUnlockerProvider: AchievementUnlockerProvider + ) {} + + public async achievementUnlocker(user: User) { + return this,this.achievementUnlockerProvider.unlockAchievementsForUser(user) + } +} diff --git a/src/achievement/seeds/initial-achievements.seed.ts b/src/achievement/seeds/initial-achievements.seed.ts new file mode 100644 index 0000000..106a2bf --- /dev/null +++ b/src/achievement/seeds/initial-achievements.seed.ts @@ -0,0 +1,12 @@ +// await achievementRepository.save([ +// { +// title: 'Mind Master', +// description: 'Complete 50 logic puzzles', +// iconUrl: 'https://cdn.mindblock.com/achievements/mind-master.svg', +// }, +// { +// title: 'Top 100', +// description: 'Reach the top 100 on the leaderboard', +// iconUrl: 'https://cdn.mindblock.com/achievements/top-100.svg', +// }, +// ]); diff --git a/src/app.module.ts b/src/app.module.ts index 007200f..4e0f253 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { PuzzleModule } from './puzzle/puzzle.module'; import { AppService } from './app.service'; import { AppController } from './app.controller'; import { GamificationModule } from './gamification/gamification.module'; +import { AchievementModule } from './achievement/achievement.module'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -54,7 +55,8 @@ import { GamificationModule } from './gamification/gamification.module'; TimeFilterModule, IQAssessmentModule, PuzzleModule, - GamificationModule + GamificationModule, + AchievementModule ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/interfaces/activeInterface.ts b/src/auth/interfaces/activeInterface.ts index 63514d4..68fcce8 100644 --- a/src/auth/interfaces/activeInterface.ts +++ b/src/auth/interfaces/activeInterface.ts @@ -3,7 +3,7 @@ export interface ActiveUserData { /**sub of type number */ - sub: number, + sub: string, /**email of type string */ email?: string diff --git a/src/badge/entities/badge.entity.ts b/src/badge/entities/badge.entity.ts index 31de588..ba88e80 100644 --- a/src/badge/entities/badge.entity.ts +++ b/src/badge/entities/badge.entity.ts @@ -1,38 +1,49 @@ -import { LeaderboardEntry } from "src/leaderboard/entities/leaderboard.entity" -import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn } from "typeorm" - -@Entity("badges") +import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; +import { User } from 'src/users/user.entity'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('badges') export class Badge { @PrimaryGeneratedColumn() - id: number + id: number; @Column({ unique: true }) - title: string // e.g. Puzzle Master, Grand Champion + title: string; // e.g. Puzzle Master, Grand Champion @Column() - description: string // Short badge description + description: string; // Short badge description @Column({ nullable: true }) - iconUrl: string // Optional: hosted badge icon + iconUrl: string; // Optional: hosted badge icon @Column() - rank: number // Used for sorting or tier logic + rank: number; // Used for sorting or tier logic @Column({ default: true }) - isActive: boolean // Whether badge is currently available + isActive: boolean; // Whether badge is currently available @Column({ default: false }) - isAutoAssigned: boolean // Whether badge is automatically assigned + isAutoAssigned: boolean; // Whether badge is automatically assigned + + @OneToMany(() => User, (user) => user.badge, { + nullable: true, + onDelete: 'SET NULL', + }) + user: User[]; - @OneToMany( - () => LeaderboardEntry, - (entry) => entry.badge, - ) - leaderboardEntries: LeaderboardEntry[] + @OneToMany(() => LeaderboardEntry, (entry) => entry.badge) + leaderboardEntries: LeaderboardEntry[]; @CreateDateColumn() - createdAt: Date + createdAt: Date; @UpdateDateColumn() - updatedAt: Date + updatedAt: Date; } diff --git a/src/iq-assessment/controllers/iq-assessment.controller.ts b/src/iq-assessment/controllers/iq-assessment.controller.ts index 0c1886c..f303ff6 100644 --- a/src/iq-assessment/controllers/iq-assessment.controller.ts +++ b/src/iq-assessment/controllers/iq-assessment.controller.ts @@ -11,21 +11,40 @@ import { UsePipes, ValidationPipe, Body, -} from "@nestjs/common" -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiQuery } from "@nestjs/swagger" -import { CreateSessionDto } from "../dto/create-session.dto" -import { SubmitAnswerDto, StandaloneSubmitAnswerDto } from "../dto/submit-answer.dto" -import { CompleteSessionDto } from "../dto/complete-session.dto" -import { SessionResponseDto, CompletedSessionResponseDto } from "../dto/session-response.dto" -import { AttemptResponseDto, UserAttemptsStatsDto } from "../dto/attempt-response.dto" -import { AnswerSubmissionResponseDto } from "../dto/answer-submission-response.dto" -import { RandomQuestionsQueryDto } from "../dto/random-questions-query.dto" -import { RandomQuestionResponseDto } from "../dto/random-question-response.dto" -import { IQAssessmentService } from "../providers/iq-assessment.service" -import { IqAttemptService } from "../providers/iq-attempt.service" +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBody, + ApiQuery, +} from '@nestjs/swagger'; +import { CreateSessionDto } from '../dto/create-session.dto'; +import { + SubmitAnswerDto, + StandaloneSubmitAnswerDto, +} from '../dto/submit-answer.dto'; +import { CompleteSessionDto } from '../dto/complete-session.dto'; +import { + SessionResponseDto, + CompletedSessionResponseDto, +} from '../dto/session-response.dto'; +import { + AttemptResponseDto, + UserAttemptsStatsDto, +} from '../dto/attempt-response.dto'; +import { AnswerSubmissionResponseDto } from '../dto/answer-submission-response.dto'; +import { RandomQuestionsQueryDto } from '../dto/random-questions-query.dto'; +import { RandomQuestionResponseDto } from '../dto/random-question-response.dto'; +import { IQAssessmentService } from '../providers/iq-assessment.service'; +import { IqAttemptService } from '../providers/iq-attempt.service'; +import { ActiveUser } from 'src/auth/decorators/activeUser.decorator'; +import { ActiveUserData } from 'src/auth/interfaces/activeInterface'; +import { SubmitQuizDto } from '../dto/submit-quiz.dto'; -@ApiTags("IQ Assessment") -@Controller("iq-assessment") +@ApiTags('IQ Assessment') +@Controller('iq-assessment') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) export class IQAssessmentController { constructor( @@ -34,28 +53,29 @@ export class IQAssessmentController { private readonly svc: IQAssessmentService, ) {} - @Post("sessions") + @Post('sessions') @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: "Start a new IQ assessment session" }) + @ApiOperation({ summary: 'Start a new IQ assessment session' }) @ApiBody({ type: CreateSessionDto }) @ApiResponse({ status: HttpStatus.CREATED, - description: "Session created successfully", + description: 'Session created successfully', type: SessionResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: "User already has an active session or not enough questions available", + description: + 'User already has an active session or not enough questions available', }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: "User not found", + description: 'User not found', }) async createSession( - createSessionDto: CreateSessionDto, + createSessionDto: CreateSessionDto, ): Promise { - console.log("Received createSessionDto:", createSessionDto) - return this.iqAssessmentService.createSession(createSessionDto) + console.log('Received createSessionDto:', createSessionDto); + return this.iqAssessmentService.createSession(createSessionDto); } @Get('sessions/:sessionId') @@ -80,65 +100,68 @@ export class IQAssessmentController { return this.iqAssessmentService.getSessionProgress(sessionId); } - @Post("sessions/submit-answer") + @Post('sessions/submit-answer') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Submit an answer for a question" }) + @ApiOperation({ summary: 'Submit an answer for a question' }) @ApiResponse({ status: HttpStatus.OK, - description: "Answer submitted successfully", + description: 'Answer submitted successfully', type: SessionResponseDto, }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: "Session or question not found", + description: 'Session or question not found', }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: "Session completed or answer already submitted for this question", + description: + 'Session completed or answer already submitted for this question', }) async submitAnswer( - submitAnswerDto: SubmitAnswerDto, + submitAnswerDto: SubmitAnswerDto, ): Promise { - return this.iqAssessmentService.submitAnswer(submitAnswerDto) + return this.iqAssessmentService.submitAnswer(submitAnswerDto); } - @Post("sessions/complete") + @Post('sessions/complete') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Complete an assessment session" }) + @ApiOperation({ summary: 'Complete an assessment session' }) @ApiResponse({ status: HttpStatus.OK, - description: "Session completed successfully", + description: 'Session completed successfully', type: CompletedSessionResponseDto, }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: "Session not found", + description: 'Session not found', }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: "Session is already completed", + description: 'Session is already completed', }) async completeSession( completeSessionDto: CompleteSessionDto, ): Promise { - return this.iqAssessmentService.completeSession(completeSessionDto.sessionId) + return this.iqAssessmentService.completeSession( + completeSessionDto.sessionId, + ); } - @Post("sessions/:sessionId/skip/:questionId") + @Post('sessions/:sessionId/skip/:questionId') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Skip a question" }) - @ApiParam({ name: "sessionId", description: "Session UUID" }) - @ApiParam({ name: "questionId", description: "Question UUID" }) + @ApiOperation({ summary: 'Skip a question' }) + @ApiParam({ name: 'sessionId', description: 'Session UUID' }) + @ApiParam({ name: 'questionId', description: 'Question UUID' }) @ApiResponse({ status: HttpStatus.OK, - description: "Question skipped successfully", + description: 'Question skipped successfully', type: SessionResponseDto, }) async skipQuestion( @Param('sessionId', ParseUUIDPipe) sessionId: string, @Param('questionId', ParseUUIDPipe) questionId: string, ): Promise { - return this.iqAssessmentService.skipQuestion(sessionId, questionId) + return this.iqAssessmentService.skipQuestion(sessionId, questionId); } @Get('users/:userId/sessions') @@ -169,11 +192,12 @@ export class IQAssessmentController { return this.iqAssessmentService.getSessionById(sessionId); } - @Get("external/random") + @Get('external/random') async getOneExternal() { - const questions: ExternalIQQuestion[] = await this.svc.fetchExternalQuestions(1) - const [q] = questions - return q + const questions: ExternalIQQuestion[] = + await this.svc.fetchExternalQuestions(1); + const [q] = questions; + return q; } // New Attempt Analytics Endpoints @@ -222,7 +246,11 @@ export class IQAssessmentController { @Get('attempts/recent') @ApiOperation({ summary: 'Get recent attempts for analytics' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of attempts to return (default: 100)' }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of attempts to return (default: 100)', + }) @ApiResponse({ status: HttpStatus.OK, description: 'Recent attempts retrieved successfully', @@ -234,24 +262,28 @@ export class IQAssessmentController { return this.iqAttemptService.getRecentAttempts(limit); } - @Get("attempts/stats/global") - @ApiOperation({ summary: "Get global attempt statistics" }) + @Get('attempts/stats/global') + @ApiOperation({ summary: 'Get global attempt statistics' }) @ApiResponse({ status: HttpStatus.OK, - description: "Global statistics retrieved successfully", + description: 'Global statistics retrieved successfully', }) async getGlobalStats() { - return this.iqAttemptService.getGlobalStats() + return this.iqAttemptService.getGlobalStats(); } - @Get("attempts/date-range") - @ApiOperation({ summary: "Get attempts within a date range" }) - @ApiQuery({ name: "startDate", description: "Start date (ISO string)" }) - @ApiQuery({ name: "endDate", description: "End date (ISO string)" }) - @ApiQuery({ name: "userId", required: false, description: "Filter by user ID" }) + @Get('attempts/date-range') + @ApiOperation({ summary: 'Get attempts within a date range' }) + @ApiQuery({ name: 'startDate', description: 'Start date (ISO string)' }) + @ApiQuery({ name: 'endDate', description: 'End date (ISO string)' }) + @ApiQuery({ + name: 'userId', + required: false, + description: 'Filter by user ID', + }) @ApiResponse({ status: HttpStatus.OK, - description: "Attempts retrieved successfully", + description: 'Attempts retrieved successfully', type: [AttemptResponseDto], }) async getAttemptsByDateRange( @@ -259,57 +291,95 @@ export class IQAssessmentController { @Query('endDate') endDate: string, @Query('userId') userId?: string, ): Promise { - return this.iqAttemptService.getAttemptsByDateRange(new Date(startDate), new Date(endDate), userId) + return this.iqAttemptService.getAttemptsByDateRange( + new Date(startDate), + new Date(endDate), + userId, + ); } - @Post("submit") + @Post('submit') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Submit a standalone answer for a question" }) + @ApiOperation({ summary: 'Submit a standalone answer for a question' }) @ApiBody({ type: StandaloneSubmitAnswerDto }) @ApiResponse({ status: HttpStatus.OK, - description: "Answer submitted successfully", + description: 'Answer submitted successfully', type: AnswerSubmissionResponseDto, }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: "Question not found", + description: 'Question not found', }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: "Invalid request data", + description: 'Invalid request data', }) async submitStandaloneAnswer( @Body() submitAnswerDto: StandaloneSubmitAnswerDto, ): Promise { - - return this.iqAssessmentService.submitStandaloneAnswer(submitAnswerDto) + return this.iqAssessmentService.submitStandaloneAnswer(submitAnswerDto); } - @Get("questions/random") - @ApiOperation({ summary: "Get random questions with optional filtering" }) - @ApiQuery({ name: "difficulty", required: false, enum: ["easy", "medium", "hard"] }) - @ApiQuery({ name: "category", required: false, enum: ["Science", "Mathematics", "Logic", "Language", "History", "Geography", "Literature", "Art", "Sports", "Entertainment", "General Knowledge"] }) - @ApiQuery({ name: "count", required: false, type: Number, minimum: 1, maximum: 50 }) + @Get('questions/random') + @ApiOperation({ summary: 'Get random questions with optional filtering' }) + @ApiQuery({ + name: 'difficulty', + required: false, + enum: ['easy', 'medium', 'hard'], + }) + @ApiQuery({ + name: 'category', + required: false, + enum: [ + 'Science', + 'Mathematics', + 'Logic', + 'Language', + 'History', + 'Geography', + 'Literature', + 'Art', + 'Sports', + 'Entertainment', + 'General Knowledge', + ], + }) + @ApiQuery({ + name: 'count', + required: false, + type: Number, + minimum: 1, + maximum: 50, + }) @ApiResponse({ status: HttpStatus.OK, - description: "Random questions retrieved successfully", + description: 'Random questions retrieved successfully', type: [RandomQuestionResponseDto], }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: "Invalid filter parameters", + description: 'Invalid filter parameters', }) async getRandomQuestions( @Query() queryDto: RandomQuestionsQueryDto, ): Promise { - const questions = await this.iqAssessmentService.getRandomQuestionsWithFilters(queryDto) - return questions.map(question => ({ + const questions = + await this.iqAssessmentService.getRandomQuestionsWithFilters(queryDto); + return questions.map((question) => ({ id: question.id, questionText: question.questionText, options: question.options, difficulty: question.difficulty, category: question.category, - })) + })); + } + + @Post('submit') + async submitQuiz( + @ActiveUser() user: ActiveUserData, + @Body() dto: SubmitQuizDto, + ) { + return this.iqAssessmentService.submitQuiz(user, dto); } } diff --git a/src/iq-assessment/iq-assessment.module.ts b/src/iq-assessment/iq-assessment.module.ts index a97e65c..a516df8 100644 --- a/src/iq-assessment/iq-assessment.module.ts +++ b/src/iq-assessment/iq-assessment.module.ts @@ -11,11 +11,13 @@ import { IQQuestion } from "./entities/iq-question.entity" import { IQAnswer } from "./entities/iq-answer.entity" import { IqAttempt } from "./entities/iq-attempt.entity" import { User } from "../users/user.entity" +import { AchievementModule } from "src/achievement/achievement.module" @Module({ imports: [ TypeOrmModule.forFeature([IQAssessmentSession, IQQuestion, IQAnswer, IqAttempt, User]), HttpModule.register({ timeout: 5000, maxRedirects: 5 }), + AchievementModule ], controllers: [IQAssessmentController, AdminIqQuestionsController], providers: [IQAssessmentService, IqAttemptService, AdminIqQuestionsService], diff --git a/src/iq-assessment/providers/iq-assessment.service.ts b/src/iq-assessment/providers/iq-assessment.service.ts index 0ee41f6..c548b9b 100644 --- a/src/iq-assessment/providers/iq-assessment.service.ts +++ b/src/iq-assessment/providers/iq-assessment.service.ts @@ -33,6 +33,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { CreateAttemptDto } from '../dto/create-attempt.dto'; import { SubmitQuizDto } from '../dto/submit-quiz.dto'; import { StartQuizDto } from '../dto/start-quiz.dto'; +import { AchievementService } from 'src/achievement/providers/achievement.service'; +import { ActiveUserData } from 'src/auth/interfaces/activeInterface'; @Injectable() export class IQAssessmentService { @@ -49,6 +51,7 @@ export class IQAssessmentService { private readonly httpService: HttpService, private readonly iqAttemptService: IqAttemptService, private readonly eventEmitter: EventEmitter2, + private readonly achievementService: AchievementService, ) {} public async fetchExternalQuestions(amount: number) { @@ -75,7 +78,7 @@ export class IQAssessmentService { }); } - async createSession( + public async createSession( createSessionDto: CreateSessionDto, ): Promise { // Verify user exists @@ -133,7 +136,9 @@ export class IQAssessmentService { return this.buildSessionResponse(savedSession, questions[0]); } - async getSessionProgress(sessionId: string): Promise { + public async getSessionProgress( + sessionId: string, + ): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId }, relations: ['answers', 'user'], @@ -154,7 +159,7 @@ export class IQAssessmentService { return this.buildSessionResponse(session, currentQuestion); } - async submitAnswer( + public async submitAnswer( submitAnswerDto: SubmitAnswerDto, ): Promise { // For session-based submissions, sessionId is required @@ -411,7 +416,7 @@ export class IQAssessmentService { }; } - async getUserSessions(userId: string): Promise { + public async getUserSessions(userId: string): Promise { return this.sessionRepository.find({ where: { userId }, order: { startTime: 'DESC' }, @@ -605,7 +610,7 @@ export class IQAssessmentService { return { questions }; } - public async submitQuiz(dto: SubmitQuizDto) { + public async submitQuiz(activeUser: ActiveUserData, dto: SubmitQuizDto) { let correctCount = 0; for (const response of dto.responses) { @@ -630,6 +635,16 @@ export class IQAssessmentService { } const score = Math.round((correctCount / dto.responses.length) * 100); + + const user = await this.userRepository.findOne({ + where: { id: activeUser.sub }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + await this.achievementService.achievementUnlocker(user); + const incorrectCount = dto.responses.length - correctCount; return { diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index 5f32a0b..0f649d7 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -1,7 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; import { userRole } from './enums/userRole.enum'; import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; +import { Badge } from 'src/badge/entities/badge.entity'; // import { PuzzleSubmission } from 'src/puzzle/entities/puzzle-submission.entity'; // import { PuzzleProgress } from 'src/puzzle/entities/puzzle-progress.entity'; @@ -11,7 +18,7 @@ export class User { @PrimaryGeneratedColumn() id: string; - @Column('varchar', { length: 150, nullable: true }) + @Column('varchar', { length: 150, nullable: true }) username?: string; @Column('varchar', { length: 150, nullable: true, unique: true }) @@ -52,6 +59,12 @@ export class User { @Column('varchar', { length: 150, nullable: true, unique: true }) starknetWallet?: string; + @ManyToOne(() => Badge, (badge) => badge.user, { + nullable: true, + cascade: ['insert', 'update'], + }) + badge: Badge; + /** * User XP points for puzzle solving. */ @@ -66,6 +79,14 @@ export class User { @Column('int', { default: 1 }) level: number; + @ApiProperty({ example: 10 }) + @Column({ default: 0 }) + puzzlesCompleted: number; + + @ApiProperty({ example: 100 }) + @Column({ type: 'int', default: 0 }) + tokens: number; + /** * One-to-many relation with puzzle progress records. */