/** * Exercise Repository Implementation * * Prisma-based implementation of Exercise Repository. * Includes caching support and query optimization. */ import { PrismaClient, Exercise, Prisma } from '@prisma/client'; import { IExerciseRepository, ExerciseFilterOptions, ExerciseWithStats, } from './interfaces/exercise.repository.interface'; import type { PaginatedResult, QueryOptions } from '@/core/types'; import { NotFoundError } from '@/core/errors'; import { logger } from '@/shared/utils/logger'; /** * Prisma Exercise Repository */ export class ExerciseRepository implements IExerciseRepository { constructor( private readonly prisma: PrismaClient, private readonly repoLogger: typeof logger ) {} async findById( id: string, options?: QueryOptions ): Promise { const exercise = await this.prisma.exercise.findUnique({ where: { id }, include: this.buildInclude(options) || null, }); return exercise; } async findWithDetails( id: string, includeSolution = false ): Promise { const exercise = await this.prisma.exercise.findUnique({ where: { id }, include: { modules: true, topics: true, exercise_attempts: { where: { status: 'CORRECT' }, select: { id: true, userId: true, pointsEarned: true, createdAt: true, }, take: 5, orderBy: { pointsEarned: 'desc' }, }, }, }); if (!exercise) { return null; } // Remove solution if not requested if (!includeSolution) { const { correctAnswer, solutionSteps, ...rest } = exercise; return rest as Exercise; } return exercise; } async findMany( filters: ExerciseFilterOptions, page: number, limit: number ): Promise> { const where = this.buildWhereClause(filters); const skip = (page - 1) * limit; const [exercises, total] = await Promise.all([ this.prisma.exercise.findMany({ where, skip, take: limit, orderBy: { order: 'asc' }, }), this.prisma.exercise.count({ where }), ]); const totalPages = Math.ceil(total / limit); return { items: exercises, total, page, limit, totalPages, hasNext: page < totalPages, hasPrev: page > 1, }; } async findByModule( moduleId: string, options?: { includeUnpublished?: boolean } ): Promise { return this.prisma.exercise.findMany({ where: { moduleId, ...(options?.includeUnpublished ? {} : { isPublished: true }), }, orderBy: { order: 'asc' }, }); } async create(data: Prisma.ExerciseCreateInput): Promise { const exercise = await this.prisma.exercise.create({ data }); this.repoLogger.info({ exerciseId: exercise.id, moduleId: exercise.moduleId, title: exercise.statement.substring(0, 50), }, 'Exercise created'); return exercise; } async update( id: string, data: Prisma.ExerciseUpdateInput ): Promise { try { const exercise = await this.prisma.exercise.update({ where: { id }, data, }); this.repoLogger.info({ exerciseId: id }, 'Exercise updated'); return exercise; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code === 'P2025') { throw new NotFoundError('Exercise', {}, undefined); } } throw error; } } async delete(id: string, hardDelete = false): Promise { if (hardDelete) { await this.prisma.exercise.delete({ where: { id } }); } else { await this.prisma.exercise.update({ where: { id }, data: { isPublished: false }, }); } this.repoLogger.info({ exerciseId: id, hardDelete }, 'Exercise deleted'); } async count(filters: ExerciseFilterOptions): Promise { const where = this.buildWhereClause(filters); return this.prisma.exercise.count({ where }); } async getStats(id: string): Promise { const [exercise, stats] = await Promise.all([ this.prisma.exercise.findUnique({ where: { id } }), this.prisma.exerciseAttempt.groupBy({ by: ['status'], where: { exerciseId: id }, _count: { id: true }, _avg: { timeSpentSeconds: true }, }), ]); if (!exercise) { return null; } const totalAttempts = stats.reduce((sum, s) => sum + s._count.id, 0); const correctAttempts = stats.find((s) => s.status === 'CORRECT')?._count.id || 0; const successRate = totalAttempts > 0 ? (correctAttempts / totalAttempts) * 100 : 0; const averageTimeSpent = stats.length > 0 ? stats.reduce((sum, s) => sum + (s._avg.timeSpentSeconds || 0), 0) / stats.length : 0; return { ...exercise, totalAttempts, correctAttempts, successRate, averageTimeSpent, }; } async findNextInModule( moduleId: string, currentOrder: number ): Promise { return this.prisma.exercise.findFirst({ where: { moduleId, order: { gt: currentOrder }, isPublished: true, }, orderBy: { order: 'asc' }, }); } async findPreviousInModule( moduleId: string, currentOrder: number ): Promise { return this.prisma.exercise.findFirst({ where: { moduleId, order: { lt: currentOrder }, isPublished: true, }, orderBy: { order: 'desc' }, }); } // ============================================ // Private Helpers // ============================================ private buildWhereClause(filters: ExerciseFilterOptions): Prisma.ExerciseWhereInput { const where: Prisma.ExerciseWhereInput = {}; if (filters.moduleId !== undefined) { where.moduleId = filters.moduleId; } if (filters.topicId !== undefined) { where.topicId = filters.topicId; } if (filters.type !== undefined) { where.type = filters.type; } if (filters.difficulty !== undefined) { where.difficulty = filters.difficulty; } if (filters.isPublished !== undefined) { where.isPublished = filters.isPublished; } if (filters.isAIGenerated !== undefined) { where.isAIGenerated = filters.isAIGenerated; } return where; } private buildInclude( options?: QueryOptions ): Prisma.ExerciseInclude | undefined { if (!options?.relations) { return undefined; } const include: Prisma.ExerciseInclude = {}; for (const relation of options.relations) { if (relation === 'module') { include.modules = true; } else if (relation === 'topic') { include.topics = true; } else if (relation === 'attempts') { include.exercise_attempts = { take: 5 }; } } return include; } }