🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled

 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:
Renato
2026-03-31 11:27:11 -03:00
commit bc43c9e772
309 changed files with 84845 additions and 0 deletions

View 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;
}
}