✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
290 lines
7.0 KiB
TypeScript
290 lines
7.0 KiB
TypeScript
/**
|
|
* 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<Exercise | null> {
|
|
const exercise = await this.prisma.exercise.findUnique({
|
|
where: { id },
|
|
include: this.buildInclude(options) || null,
|
|
});
|
|
|
|
return exercise;
|
|
}
|
|
|
|
async findWithDetails(
|
|
id: string,
|
|
includeSolution = false
|
|
): Promise<Exercise | null> {
|
|
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<PaginatedResult<Exercise>> {
|
|
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<Exercise[]> {
|
|
return this.prisma.exercise.findMany({
|
|
where: {
|
|
moduleId,
|
|
...(options?.includeUnpublished ? {} : { isPublished: true }),
|
|
},
|
|
orderBy: { order: 'asc' },
|
|
});
|
|
}
|
|
|
|
async create(data: Prisma.ExerciseCreateInput): Promise<Exercise> {
|
|
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<Exercise> {
|
|
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<void> {
|
|
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<number> {
|
|
const where = this.buildWhereClause(filters);
|
|
return this.prisma.exercise.count({ where });
|
|
}
|
|
|
|
async getStats(id: string): Promise<ExerciseWithStats | null> {
|
|
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<Exercise | null> {
|
|
return this.prisma.exercise.findFirst({
|
|
where: {
|
|
moduleId,
|
|
order: { gt: currentOrder },
|
|
isPublished: true,
|
|
},
|
|
orderBy: { order: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findPreviousInModule(
|
|
moduleId: string,
|
|
currentOrder: number
|
|
): Promise<Exercise | null> {
|
|
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;
|
|
}
|
|
}
|