🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ 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 ✅
This commit is contained in:
289
backend/src/repositories/exercise.repository.ts
Normal file
289
backend/src/repositories/exercise.repository.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user