From 43441691cc1e4908b218bc90af75c4c9d09f8c36 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 19 Dec 2025 01:39:38 -0600 Subject: [PATCH] feat: Sincronizar cambios desde workspace-old MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - exercise-responses.dto.ts - exercise-responses.service.ts Database DDL: - 00-prerequisites.sql - 04-initialize_user_stats.sql Scripts: - init-database.sh Seeds: - 01-tenants.sql (dev + prod) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../teacher/dto/exercise-responses.dto.ts | 43 -- .../services/exercise-responses.service.ts | 395 ++++++------------ .../apps/database/ddl/00-prerequisites.sql | 45 +- .../functions/04-initialize_user_stats.sql | 16 +- .../apps/database/scripts/init-database.sh | 43 +- .../seeds/dev/auth_management/01-tenants.sql | 31 -- .../seeds/prod/auth_management/01-tenants.sql | 31 -- 7 files changed, 149 insertions(+), 455 deletions(-) diff --git a/projects/gamilit/apps/backend/src/modules/teacher/dto/exercise-responses.dto.ts b/projects/gamilit/apps/backend/src/modules/teacher/dto/exercise-responses.dto.ts index c56cc6e..3ac7725 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/dto/exercise-responses.dto.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/dto/exercise-responses.dto.ts @@ -35,24 +35,6 @@ export enum SortOrder { DESC = 'desc', } -/** - * G20 FIX: Source of the response (which table it comes from) - */ -export enum ResponseSource { - ATTEMPT = 'attempt', - SUBMISSION = 'submission', -} - -/** - * G20 FIX: Status for exercise submissions (only for source='submission') - */ -export enum ExerciseSubmissionStatus { - DRAFT = 'draft', - SUBMITTED = 'submitted', - GRADED = 'graded', - REVIEWED = 'reviewed', -} - /** * Query DTO for filtering exercise attempts */ @@ -265,31 +247,6 @@ export class AttemptResponseDto { example: '2024-11-24T10:30:00Z', }) submitted_at!: string; - - // G20 FIX: New fields to support exercise_submissions table - @ApiPropertyOptional({ - description: 'Source of the response (attempt=auto-graded, submission=manual review)', - enum: ResponseSource, - example: 'attempt', - }) - source?: ResponseSource; - - @ApiPropertyOptional({ - description: 'Status (only for submissions - draft, submitted, graded, reviewed)', - enum: ExerciseSubmissionStatus, - example: 'submitted', - }) - status?: ExerciseSubmissionStatus; - - @ApiPropertyOptional({ - description: 'Feedback from teacher (only for submissions)', - }) - feedback?: string; - - @ApiPropertyOptional({ - description: 'Whether the exercise requires manual grading', - }) - requires_manual_grading?: boolean; } /** diff --git a/projects/gamilit/apps/backend/src/modules/teacher/services/exercise-responses.service.ts b/projects/gamilit/apps/backend/src/modules/teacher/services/exercise-responses.service.ts index dc72580..f4b53de 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/services/exercise-responses.service.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/services/exercise-responses.service.ts @@ -15,8 +15,6 @@ import { AttemptResponseDto, AttemptDetailDto, AttemptsListResponseDto, - ResponseSource, - ExerciseSubmissionStatus, } from '../dto/exercise-responses.dto'; /** @@ -76,13 +74,6 @@ export class ExerciseResponsesService { * is_correct: true, * }); */ - /** - * G20 FIX: Get paginated list of exercise responses from BOTH tables - * - exercise_attempts: Auto-graded exercises (modules 1-3) - * - exercise_submissions: Manual review exercises (modules 4-5) - * - * Uses UNION query to combine both sources. - */ async getAttempts( userId: string, query: GetAttemptsQueryDto, @@ -98,175 +89,107 @@ export class ExerciseResponsesService { const limit = query.limit || 20; const offset = (page - 1) * limit; - // Sorting - use alias for UNION compatibility + // Sorting const sortField = query.sort_by === 'score' - ? 'score' + ? 'attempt.score' : query.sort_by === 'time' - ? 'time_spent_seconds' - : 'submitted_at'; + ? 'attempt.time_spent_seconds' + : 'attempt.submitted_at'; const sortOrder = query.sort_order?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; - // Build base params + // Build WHERE conditions dynamically + const conditions: string[] = [ + '(c.teacher_id = $1 OR EXISTS (SELECT 1 FROM social_features.teacher_classrooms tc WHERE tc.teacher_id = $1 AND tc.classroom_id = c.id))', + 'profile.tenant_id = $2', + ]; const params: any[] = [teacherId, tenantId]; let paramIndex = 3; - // Build dynamic conditions for attempts - // Note: teacher access is validated via classrooms.teacher_id - const buildConditions = () => { - const conditions: string[] = [ - 'c.teacher_id = $1', - 'profile.tenant_id = $2', - ]; - return conditions; - }; - - let attemptsConditions = buildConditions(); - let submissionsConditions = buildConditions(); - - // Add dynamic filters - const dynamicParams: any[] = []; - if (query.student_id) { - attemptsConditions.push(`profile.id = $${paramIndex}`); - submissionsConditions.push(`profile.id = $${paramIndex}`); - dynamicParams.push(query.student_id); + conditions.push(`profile.id = $${paramIndex}`); + params.push(query.student_id); paramIndex++; } if (query.exercise_id) { - attemptsConditions.push(`attempt.exercise_id = $${paramIndex}`); - submissionsConditions.push(`sub.exercise_id = $${paramIndex}`); - dynamicParams.push(query.exercise_id); + conditions.push(`attempt.exercise_id = $${paramIndex}`); + params.push(query.exercise_id); paramIndex++; } if (query.module_id) { - attemptsConditions.push(`exercise.module_id = $${paramIndex}`); - submissionsConditions.push(`exercise.module_id = $${paramIndex}`); - dynamicParams.push(query.module_id); + conditions.push(`exercise.module_id = $${paramIndex}`); + params.push(query.module_id); paramIndex++; } if (query.classroom_id) { - attemptsConditions.push(`c.id = $${paramIndex}`); - submissionsConditions.push(`c.id = $${paramIndex}`); - dynamicParams.push(query.classroom_id); + conditions.push(`c.id = $${paramIndex}`); + params.push(query.classroom_id); paramIndex++; } if (query.from_date) { - attemptsConditions.push(`attempt.submitted_at >= $${paramIndex}`); - submissionsConditions.push(`sub.submitted_at >= $${paramIndex}`); - dynamicParams.push(query.from_date); + conditions.push(`attempt.submitted_at >= $${paramIndex}`); + params.push(query.from_date); paramIndex++; } if (query.to_date) { - attemptsConditions.push(`attempt.submitted_at <= $${paramIndex}`); - submissionsConditions.push(`sub.submitted_at <= $${paramIndex}`); - dynamicParams.push(query.to_date); + conditions.push(`attempt.submitted_at <= $${paramIndex}`); + params.push(query.to_date); paramIndex++; } if (query.is_correct !== undefined) { - attemptsConditions.push(`attempt.is_correct = $${paramIndex}`); - submissionsConditions.push(`sub.is_correct = $${paramIndex}`); - dynamicParams.push(query.is_correct); + conditions.push(`attempt.is_correct = $${paramIndex}`); + params.push(query.is_correct); paramIndex++; } if (query.student_search) { const searchPattern = `%${query.student_search}%`; - const searchCondition = `( + conditions.push(`( profile.first_name ILIKE $${paramIndex} OR profile.last_name ILIKE $${paramIndex} OR CONCAT(profile.first_name, ' ', profile.last_name) ILIKE $${paramIndex} - )`; - attemptsConditions.push(searchCondition); - submissionsConditions.push(searchCondition); - dynamicParams.push(searchPattern); + )`); + params.push(searchPattern); paramIndex++; } - // Submissions: exclude drafts - submissionsConditions.push("sub.status != 'draft'"); + const whereClause = conditions.join(' AND '); - const attemptsWhere = attemptsConditions.join(' AND '); - const submissionsWhere = submissionsConditions.join(' AND '); - - // Merge dynamic params - params.push(...dynamicParams); - - // G20 FIX: UNION query combining both tables - // Note: to_jsonb(sub.comodines_used) converts text[] to jsonb for UNION compatibility + // Main query using raw SQL for cross-schema JOINs const sql = ` - SELECT * FROM ( - -- Ejercicios autocorregibles (exercise_attempts) - SELECT - 'attempt' AS source, - attempt.id AS id, - attempt.user_id AS user_id, - attempt.exercise_id AS exercise_id, - attempt.attempt_number AS attempt_number, - attempt.submitted_answers AS submitted_answers, - attempt.is_correct AS is_correct, - attempt.score AS score, - attempt.time_spent_seconds AS time_spent_seconds, - attempt.hints_used AS hints_used, - attempt.comodines_used AS comodines_used, - attempt.xp_earned AS xp_earned, - attempt.ml_coins_earned AS ml_coins_earned, - attempt.submitted_at AS submitted_at, - NULL::text AS status, - NULL::text AS feedback, - false AS requires_manual_grading, - profile.id AS profile_id, - profile.first_name AS first_name, - profile.last_name AS last_name, - exercise.title AS exercise_title, - module.title AS module_name - FROM progress_tracking.exercise_attempts attempt - LEFT JOIN auth_management.profiles profile ON profile.user_id = attempt.user_id - LEFT JOIN educational_content.exercises exercise ON exercise.id = attempt.exercise_id - LEFT JOIN educational_content.modules module ON module.id = exercise.module_id - LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id - LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id - WHERE ${attemptsWhere} - - UNION ALL - - -- Ejercicios de revision manual (exercise_submissions) - SELECT - 'submission' AS source, - sub.id AS id, - sub.user_id AS user_id, - sub.exercise_id AS exercise_id, - sub.attempt_number AS attempt_number, - sub.answer_data AS submitted_answers, - sub.is_correct AS is_correct, - sub.score AS score, - sub.time_spent_seconds AS time_spent_seconds, - sub.hints_count AS hints_used, - to_jsonb(sub.comodines_used) AS comodines_used, - sub.xp_earned AS xp_earned, - sub.ml_coins_earned AS ml_coins_earned, - sub.submitted_at AS submitted_at, - sub.status AS status, - sub.feedback AS feedback, - true AS requires_manual_grading, - profile.id AS profile_id, - profile.first_name AS first_name, - profile.last_name AS last_name, - exercise.title AS exercise_title, - module.title AS module_name - FROM progress_tracking.exercise_submissions sub - LEFT JOIN auth_management.profiles profile ON profile.user_id = sub.user_id - LEFT JOIN educational_content.exercises exercise ON exercise.id = sub.exercise_id - LEFT JOIN educational_content.modules module ON module.id = exercise.module_id - LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id - LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id - WHERE ${submissionsWhere} - ) AS combined + SELECT + attempt.id AS attempt_id, + attempt.user_id AS attempt_user_id, + attempt.exercise_id AS attempt_exercise_id, + attempt.attempt_number AS attempt_attempt_number, + attempt.submitted_answers AS attempt_submitted_answers, + attempt.is_correct AS attempt_is_correct, + attempt.score AS attempt_score, + attempt.time_spent_seconds AS attempt_time_spent_seconds, + attempt.hints_used AS attempt_hints_used, + attempt.comodines_used AS attempt_comodines_used, + attempt.xp_earned AS attempt_xp_earned, + attempt.ml_coins_earned AS attempt_ml_coins_earned, + attempt.submitted_at AS attempt_submitted_at, + profile.id AS profile_id, + profile.first_name AS profile_first_name, + profile.last_name AS profile_last_name, + exercise.id AS exercise_id, + exercise.title AS exercise_title, + module.id AS module_id, + module.title AS module_name + FROM progress_tracking.exercise_attempts attempt + LEFT JOIN auth_management.profiles profile ON profile.user_id = attempt.user_id + LEFT JOIN educational_content.exercises exercise ON exercise.id = attempt.exercise_id + LEFT JOIN educational_content.modules module ON module.id = exercise.module_id + LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id + LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id + WHERE ${whereClause} ORDER BY ${sortField} ${sortOrder} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; @@ -276,25 +199,15 @@ export class ExerciseResponsesService { // Execute main query const rawResults = await this.dataSource.query(sql, params); - // Count query for both tables + // Count query (separate for efficiency) const countSql = ` - SELECT ( - (SELECT COUNT(DISTINCT attempt.id) - FROM progress_tracking.exercise_attempts attempt - LEFT JOIN auth_management.profiles profile ON profile.user_id = attempt.user_id - LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id - LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id - LEFT JOIN educational_content.exercises exercise ON exercise.id = attempt.exercise_id - WHERE ${attemptsWhere}) - + - (SELECT COUNT(DISTINCT sub.id) - FROM progress_tracking.exercise_submissions sub - LEFT JOIN auth_management.profiles profile ON profile.user_id = sub.user_id - LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id - LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id - LEFT JOIN educational_content.exercises exercise ON exercise.id = sub.exercise_id - WHERE ${submissionsWhere}) - ) AS total + SELECT COUNT(DISTINCT attempt.id) as total + FROM progress_tracking.exercise_attempts attempt + LEFT JOIN auth_management.profiles profile ON profile.user_id = attempt.user_id + LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id + LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id + LEFT JOIN educational_content.exercises exercise ON exercise.id = attempt.exercise_id + WHERE ${whereClause} `; // Remove LIMIT/OFFSET params for count query @@ -302,29 +215,24 @@ export class ExerciseResponsesService { const countResult = await this.dataSource.query(countSql, countParams); const total = parseInt(countResult[0]?.total || '0', 10); - // Transform raw results to DTOs with new G20 fields + // Transform raw results to DTOs const data: AttemptResponseDto[] = rawResults.map((row: any) => ({ - id: row.id, - student_id: row.user_id, - student_name: `${row.first_name || ''} ${row.last_name || ''}`.trim() || 'Unknown', - exercise_id: row.exercise_id, + id: row.attempt_id, + student_id: row.attempt_user_id, + student_name: `${row.profile_first_name || ''} ${row.profile_last_name || ''}`.trim() || 'Unknown', + exercise_id: row.attempt_exercise_id, exercise_title: row.exercise_title || 'Unknown Exercise', module_name: row.module_name || 'Unknown Module', - attempt_number: row.attempt_number, - submitted_answers: row.submitted_answers, - is_correct: row.is_correct ?? false, - score: row.score ?? 0, - time_spent_seconds: row.time_spent_seconds ?? 0, - hints_used: row.hints_used ?? 0, - comodines_used: Array.isArray(row.comodines_used) ? row.comodines_used : (row.comodines_used || []), - xp_earned: row.xp_earned ?? 0, - ml_coins_earned: row.ml_coins_earned ?? 0, - submitted_at: row.submitted_at ? new Date(row.submitted_at).toISOString() : new Date().toISOString(), - // G20 FIX: New fields - source: row.source === 'submission' ? ResponseSource.SUBMISSION : ResponseSource.ATTEMPT, - status: row.status as ExerciseSubmissionStatus | undefined, - feedback: row.feedback || undefined, - requires_manual_grading: row.requires_manual_grading ?? false, + attempt_number: row.attempt_attempt_number, + submitted_answers: row.attempt_submitted_answers, + is_correct: row.attempt_is_correct ?? false, + score: row.attempt_score ?? 0, + time_spent_seconds: row.attempt_time_spent_seconds ?? 0, + hints_used: row.attempt_hints_used, + comodines_used: row.attempt_comodines_used, + xp_earned: row.attempt_xp_earned, + ml_coins_earned: row.attempt_ml_coins_earned, + submitted_at: row.attempt_submitted_at ? new Date(row.attempt_submitted_at).toISOString() : new Date().toISOString(), })); return { @@ -389,17 +297,13 @@ export class ExerciseResponsesService { } /** - * Get detailed information for a specific attempt or submission - * - * G20 FIX: Now searches BOTH tables: - * - exercise_attempts: Auto-graded exercises (modules 1-3) - * - exercise_submissions: Manual review exercises (modules 4-5) + * Get detailed information for a specific attempt * * @param userId - Teacher's user ID (from auth.users) - * @param attemptId - Attempt or Submission ID + * @param attemptId - Attempt ID * @returns Detailed attempt information including correct answers * - * @throws NotFoundException if attempt/submission not found + * @throws NotFoundException if attempt not found * @throws ForbiddenException if teacher doesn't have access */ async getAttemptDetail( @@ -411,34 +315,30 @@ export class ExerciseResponsesService { const teacherId = teacherProfile.id; const tenantId = teacherProfile.tenant_id; - // G20 FIX: First try exercise_attempts table - const attemptSql = ` + // Raw SQL query for cross-schema JOINs + const sql = ` SELECT - 'attempt' AS source, - attempt.id AS record_id, - attempt.user_id AS user_id, - attempt.exercise_id AS exercise_id, - attempt.attempt_number AS attempt_number, - attempt.submitted_answers AS submitted_answers, - attempt.is_correct AS is_correct, - attempt.score AS score, - attempt.time_spent_seconds AS time_spent_seconds, - attempt.hints_used AS hints_used, - attempt.comodines_used AS comodines_used, - attempt.xp_earned AS xp_earned, - attempt.ml_coins_earned AS ml_coins_earned, - attempt.submitted_at AS submitted_at, - NULL::text AS status, - NULL::text AS feedback, - false AS requires_manual_grading, + attempt.id AS attempt_id, + attempt.user_id AS attempt_user_id, + attempt.exercise_id AS attempt_exercise_id, + attempt.attempt_number AS attempt_attempt_number, + attempt.submitted_answers AS attempt_submitted_answers, + attempt.is_correct AS attempt_is_correct, + attempt.score AS attempt_score, + attempt.time_spent_seconds AS attempt_time_spent_seconds, + attempt.hints_used AS attempt_hints_used, + attempt.comodines_used AS attempt_comodines_used, + attempt.xp_earned AS attempt_xp_earned, + attempt.ml_coins_earned AS attempt_ml_coins_earned, + attempt.submitted_at AS attempt_submitted_at, profile.id AS profile_id, - profile.first_name AS first_name, - profile.last_name AS last_name, - exercise.id AS ex_id, + profile.first_name AS profile_first_name, + profile.last_name AS profile_last_name, + exercise.id AS exercise_id, exercise.title AS exercise_title, exercise.exercise_type AS exercise_type, exercise.content AS exercise_content, - exercise.max_points AS max_points, + exercise.max_points AS exercise_max_points, module.id AS module_id, module.title AS module_name FROM progress_tracking.exercise_attempts attempt @@ -449,62 +349,15 @@ export class ExerciseResponsesService { LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id WHERE attempt.id = $1 AND profile.tenant_id = $2 - AND c.teacher_id = $3 + AND (c.teacher_id = $3 OR EXISTS (SELECT 1 FROM social_features.teacher_classrooms tc WHERE tc.teacher_id = $3 AND tc.classroom_id = c.id)) LIMIT 1 `; - let results = await this.dataSource.query(attemptSql, [attemptId, tenantId, teacherId]); - let row = results[0]; - - // G20 FIX: If not found in attempts, try exercise_submissions - if (!row) { - const submissionSql = ` - SELECT - 'submission' AS source, - sub.id AS record_id, - sub.user_id AS user_id, - sub.exercise_id AS exercise_id, - sub.attempt_number AS attempt_number, - sub.answer_data AS submitted_answers, - sub.is_correct AS is_correct, - sub.score AS score, - sub.time_spent_seconds AS time_spent_seconds, - sub.hints_count AS hints_used, - to_jsonb(sub.comodines_used) AS comodines_used, - sub.xp_earned AS xp_earned, - sub.ml_coins_earned AS ml_coins_earned, - sub.submitted_at AS submitted_at, - sub.status AS status, - sub.feedback AS feedback, - true AS requires_manual_grading, - profile.id AS profile_id, - profile.first_name AS first_name, - profile.last_name AS last_name, - exercise.id AS ex_id, - exercise.title AS exercise_title, - exercise.exercise_type AS exercise_type, - exercise.content AS exercise_content, - exercise.max_points AS max_points, - module.id AS module_id, - module.title AS module_name - FROM progress_tracking.exercise_submissions sub - LEFT JOIN auth_management.profiles profile ON profile.user_id = sub.user_id - LEFT JOIN educational_content.exercises exercise ON exercise.id = sub.exercise_id - LEFT JOIN educational_content.modules module ON module.id = exercise.module_id - LEFT JOIN social_features.classroom_members cm ON cm.student_id = profile.id - LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id - WHERE sub.id = $1 - AND profile.tenant_id = $2 - AND c.teacher_id = $3 - LIMIT 1 - `; - - results = await this.dataSource.query(submissionSql, [attemptId, tenantId, teacherId]); - row = results[0]; - } + const results = await this.dataSource.query(sql, [attemptId, tenantId, teacherId]); + const row = results[0]; if (!row) { - throw new NotFoundException(`Attempt/Submission ${attemptId} not found or access denied`); + throw new NotFoundException(`Attempt ${attemptId} not found or access denied`); } // Parse exercise content if it's a string @@ -524,31 +377,26 @@ export class ExerciseResponsesService { const correctAnswer = this.extractCorrectAnswers(exerciseContent, row.exercise_type); return { - id: row.record_id, - student_id: row.user_id, - student_name: `${row.first_name || ''} ${row.last_name || ''}`.trim() || 'Unknown', - exercise_id: row.exercise_id, + id: row.attempt_id, + student_id: row.attempt_user_id, + student_name: `${row.profile_first_name || ''} ${row.profile_last_name || ''}`.trim() || 'Unknown', + exercise_id: row.attempt_exercise_id, exercise_title: row.exercise_title || 'Unknown Exercise', module_name: row.module_name || 'Unknown Module', - attempt_number: row.attempt_number, - submitted_answers: row.submitted_answers, - is_correct: row.is_correct ?? false, - score: row.score ?? 0, - time_spent_seconds: row.time_spent_seconds ?? 0, - hints_used: row.hints_used ?? 0, - comodines_used: Array.isArray(row.comodines_used) ? row.comodines_used : (row.comodines_used || []), - xp_earned: row.xp_earned ?? 0, - ml_coins_earned: row.ml_coins_earned ?? 0, - submitted_at: row.submitted_at ? new Date(row.submitted_at).toISOString() : new Date().toISOString(), - // G20 FIX: New fields - source: row.source === 'submission' ? ResponseSource.SUBMISSION : ResponseSource.ATTEMPT, - status: row.status as ExerciseSubmissionStatus | undefined, - feedback: row.feedback || undefined, - requires_manual_grading: row.requires_manual_grading ?? false, + attempt_number: row.attempt_attempt_number, + submitted_answers: row.attempt_submitted_answers, + is_correct: row.attempt_is_correct ?? false, + score: row.attempt_score ?? 0, + time_spent_seconds: row.attempt_time_spent_seconds ?? 0, + hints_used: row.attempt_hints_used, + comodines_used: row.attempt_comodines_used, + xp_earned: row.attempt_xp_earned, + ml_coins_earned: row.attempt_ml_coins_earned, + submitted_at: row.attempt_submitted_at ? new Date(row.attempt_submitted_at).toISOString() : new Date().toISOString(), // Additional detail fields correct_answer: correctAnswer, exercise_type: row.exercise_type || 'unknown', - max_score: row.max_points || 100, + max_score: row.exercise_max_points || 100, }; } @@ -674,7 +522,6 @@ export class ExerciseResponsesService { const tenantId = teacherProfile.tenant_id; // Raw SQL for cross-schema verification - // G20 FIX: Simplified to use only c.teacher_id (removed non-existent teacher_classrooms reference) const sql = ` SELECT 1 FROM auth_management.profiles profile @@ -682,7 +529,7 @@ export class ExerciseResponsesService { LEFT JOIN social_features.classrooms c ON c.id = cm.classroom_id WHERE profile.id = $1 AND profile.tenant_id = $2 - AND c.teacher_id = $3 + AND (c.teacher_id = $3 OR EXISTS (SELECT 1 FROM social_features.teacher_classrooms tc WHERE tc.teacher_id = $3 AND tc.classroom_id = c.id)) LIMIT 1 `; diff --git a/projects/gamilit/apps/database/ddl/00-prerequisites.sql b/projects/gamilit/apps/database/ddl/00-prerequisites.sql index 8531c21..7dcb814 100644 --- a/projects/gamilit/apps/database/ddl/00-prerequisites.sql +++ b/projects/gamilit/apps/database/ddl/00-prerequisites.sql @@ -210,20 +210,9 @@ DO $$ BEGIN EXCEPTION WHEN duplicate_object THEN null; END $$; -- 📚 Documentación: educational_content.difficulty_level --- RESTAURADO (2025-12-19): Necesario en prerequisites para que tablas puedan crearse --- 8 niveles CEFR: beginner (A1) → native (C2+) -DO $$ BEGIN - CREATE TYPE educational_content.difficulty_level AS ENUM ( - 'beginner', -- A1: Nivel básico de supervivencia - 'elementary', -- A2: Nivel elemental - 'pre_intermediate', -- B1: Pre-intermedio - 'intermediate', -- B2: Intermedio - 'upper_intermediate', -- C1: Intermedio avanzado - 'advanced', -- C2: Avanzado - 'proficient', -- C2+: Competente - 'native' -- Nativo: Dominio total - ); -EXCEPTION WHEN duplicate_object THEN null; END $$; +-- REMOVIDO (2025-11-11): Migrado a ddl/schemas/educational_content/enums/difficulty_level.sql +-- Razón: Evitar duplicación (Política de Carga Limpia) +-- El ENUM se define en el schema específico con documentación completa (8 niveles CEFR) -- 📚 Documentación: educational_content.module_status -- VERSIÓN: 1.2 (2025-11-23) - Agregado 'backlog' para módulos fuera de alcance de entrega @@ -239,16 +228,9 @@ DO $$ BEGIN EXCEPTION WHEN duplicate_object THEN null; END $$; -- 📚 Documentación: content_management.content_status --- RESTAURADO (2025-12-19): Necesario en prerequisites para que tablas puedan crearse --- Estados del ciclo de vida del contenido educativo -DO $$ BEGIN - CREATE TYPE content_management.content_status AS ENUM ( - 'draft', -- Borrador - 'published', -- Publicado - 'archived', -- Archivado - 'under_review' -- En revisión - ); -EXCEPTION WHEN duplicate_object THEN null; END $$; +-- REMOVIDO (2025-11-11): Migrado a ddl/schemas/content_management/enums/content_status.sql +-- Razón: Evitar duplicación (Política de Carga Limpia) +-- El ENUM se define en el schema específico con documentación completa DO $$ BEGIN CREATE TYPE educational_content.cognitive_level AS ENUM ('recordar', 'comprender', 'aplicar', 'analizar', 'evaluar', 'crear'); @@ -274,18 +256,9 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; -- 4. ENUMs de Progreso -- 📚 Documentación: progress_tracking.progress_status --- RESTAURADO (2025-12-19): Necesario en prerequisites para que tablas puedan crearse --- Estados de progreso para módulos y ejercicios -DO $$ BEGIN - CREATE TYPE progress_tracking.progress_status AS ENUM ( - 'not_started', -- El usuario no ha comenzado el contenido - 'in_progress', -- El usuario está trabajando en el contenido - 'completed', -- El usuario completó el contenido exitosamente - 'needs_review', -- El contenido fue completado pero requiere revisión - 'mastered', -- El usuario dominó el contenido (nivel de excelencia) - 'abandoned' -- El usuario abandonó el contenido sin completar - ); -EXCEPTION WHEN duplicate_object THEN null; END $$; +-- REMOVIDO (2025-11-11): Migrado a ddl/schemas/progress_tracking/enums/progress_status.sql +-- Razón: Evitar duplicación (Política de Carga Limpia) +-- El ENUM se define en el schema específico con documentación exhaustiva (112 líneas) -- 📚 Documentación: progress_tracking.attempt_status -- Requerimiento: docs/01-requerimientos/04-progreso-seguimiento/RF-PRG-001-estados-progreso.md diff --git a/projects/gamilit/apps/database/ddl/schemas/gamilit/functions/04-initialize_user_stats.sql b/projects/gamilit/apps/database/ddl/schemas/gamilit/functions/04-initialize_user_stats.sql index 69d40c7..19d7c39 100644 --- a/projects/gamilit/apps/database/ddl/schemas/gamilit/functions/04-initialize_user_stats.sql +++ b/projects/gamilit/apps/database/ddl/schemas/gamilit/functions/04-initialize_user_stats.sql @@ -9,8 +9,6 @@ -- #1: Added module_progress initialization (CRITICAL) -- #2: Added ON CONFLICT to user_ranks (prevents duplicate key errors) -- #3: Kept initialize_user_missions commented (function not implemented yet) --- Updated: 2025-12-19 - BUG FIX CRÍTICO: --- #4: Todas las tablas tienen FK a profiles.id, usar NEW.id en todos los inserts -- ===================================================== CREATE OR REPLACE FUNCTION gamilit.initialize_user_stats() @@ -21,16 +19,14 @@ BEGIN -- Initialize gamification for students, teachers, and admins -- Only these roles have gamification enabled IF NEW.role IN ('student', 'admin_teacher', 'super_admin') THEN - -- IMPORTANTE: Todas las tablas (user_stats, user_ranks, comodines_inventory, module_progress) - -- tienen FK user_id que referencia profiles.id, NO auth.users.id - -- Por lo tanto, debemos usar NEW.id (profiles.id) en todos los inserts + -- Use NEW.user_id which points to auth.users.id (correct foreign key reference) INSERT INTO gamification_system.user_stats ( user_id, tenant_id, ml_coins, ml_coins_earned_total ) VALUES ( - NEW.id, -- FIXED 2025-12-19: usar NEW.id (profiles.id), FK apunta a profiles(id) + NEW.user_id, -- Fixed: usar user_id en lugar de id NEW.tenant_id, 100, -- Welcome bonus 100 @@ -38,25 +34,27 @@ BEGIN ON CONFLICT (user_id) DO NOTHING; -- Prevent duplicates -- Create comodines inventory + -- IMPORTANT: comodines_inventory.user_id references profiles.id (NOT auth.users.id) INSERT INTO gamification_system.comodines_inventory ( user_id ) VALUES ( - NEW.id -- profiles.id - FK apunta a profiles(id) + NEW.id -- CORRECTED: usar NEW.id (profiles.id) porque FK apunta a profiles(id) ) ON CONFLICT (user_id) DO NOTHING; -- Create initial user rank (starting with Ajaw - lowest rank) + -- BUG FIX #2: Use WHERE NOT EXISTS instead of ON CONFLICT (no unique constraint on user_id) INSERT INTO gamification_system.user_ranks ( user_id, tenant_id, current_rank ) SELECT - NEW.id, -- FIXED 2025-12-19: usar NEW.id (profiles.id), FK apunta a profiles(id) + NEW.user_id, NEW.tenant_id, 'Ajaw'::gamification_system.maya_rank WHERE NOT EXISTS ( - SELECT 1 FROM gamification_system.user_ranks WHERE user_id = NEW.id + SELECT 1 FROM gamification_system.user_ranks WHERE user_id = NEW.user_id ); -- BUG FIX #1: Initialize module progress for all active modules diff --git a/projects/gamilit/apps/database/scripts/init-database.sh b/projects/gamilit/apps/database/scripts/init-database.sh index 688d0d1..cbff9f4 100755 --- a/projects/gamilit/apps/database/scripts/init-database.sh +++ b/projects/gamilit/apps/database/scripts/init-database.sh @@ -821,24 +821,27 @@ load_seeds() { local failed=0 # Array con orden específico respetando dependencias - # IMPORTANTE: Los módulos deben cargarse ANTES de los profiles - # porque el trigger trg_initialize_user_stats crea module_progress - # y necesita que los módulos ya existan local seed_files=( - # === FASE 1: INFRAESTRUCTURA BASE === "$SEEDS_DIR/auth_management/01-tenants.sql" "$SEEDS_DIR/auth_management/02-auth_providers.sql" + "$SEEDS_DIR/auth/01-demo-users.sql" + "$SEEDS_DIR/auth/02-production-users.sql" # ✅ PROD: Usuarios reales (13) + "$SEEDS_DIR/auth/02-test-users.sql" # ✅ DEV: Usuarios de prueba (3) + "$SEEDS_DIR/auth_management/03-profiles.sql" + "$SEEDS_DIR/auth_management/04-profiles-testing.sql" # ✅ PROD: Profiles @gamilit.com (3) + "$SEEDS_DIR/auth_management/05-profiles-demo.sql" # ✅ PROD: Profiles demo (20) + "$SEEDS_DIR/auth_management/06-profiles-production.sql" # ✅ PROD: Profiles reales (13) + "$SEEDS_DIR/auth_management/04-user_roles.sql" + "$SEEDS_DIR/auth_management/05-user_preferences.sql" + "$SEEDS_DIR/auth_management/06-auth_attempts.sql" + "$SEEDS_DIR/auth_management/07-security_events.sql" "$SEEDS_DIR/system_configuration/01-system_settings.sql" "$SEEDS_DIR/system_configuration/02-feature_flags.sql" - - # === FASE 2: GAMIFICATION BASE (antes de profiles) === "$SEEDS_DIR/gamification_system/01-achievement_categories.sql" "$SEEDS_DIR/gamification_system/02-leaderboard_metadata.sql" "$SEEDS_DIR/gamification_system/03-maya_ranks.sql" "$SEEDS_DIR/gamification_system/04-achievements.sql" - - # === FASE 3: MÓDULOS Y EJERCICIOS (ANTES de profiles - CRÍTICO) === - # El trigger trg_initialize_user_stats necesita módulos publicados + "$SEEDS_DIR/gamification_system/04-initialize_user_gamification.sql" "$SEEDS_DIR/educational_content/01-modules.sql" "$SEEDS_DIR/educational_content/02-exercises-module1.sql" "$SEEDS_DIR/educational_content/03-exercises-module2.sql" @@ -846,28 +849,6 @@ load_seeds() { "$SEEDS_DIR/educational_content/05-exercises-module4.sql" "$SEEDS_DIR/educational_content/06-exercises-module5.sql" "$SEEDS_DIR/educational_content/07-assessment-rubrics.sql" - - # === FASE 4: USUARIOS (auth.users) === - "$SEEDS_DIR/auth/01-demo-users.sql" - "$SEEDS_DIR/auth/02-production-users.sql" - "$SEEDS_DIR/auth/02-test-users.sql" - - # === FASE 5: PROFILES (activa trigger que crea module_progress) === - "$SEEDS_DIR/auth_management/03-profiles.sql" - "$SEEDS_DIR/auth_management/04-profiles-complete.sql" - "$SEEDS_DIR/auth_management/04-profiles-testing.sql" - "$SEEDS_DIR/auth_management/05-profiles-demo.sql" - "$SEEDS_DIR/auth_management/06-profiles-production.sql" - "$SEEDS_DIR/auth_management/07-profiles-production-additional.sql" - "$SEEDS_DIR/auth_management/04-user_roles.sql" - "$SEEDS_DIR/auth_management/05-user_preferences.sql" - "$SEEDS_DIR/auth_management/06-auth_attempts.sql" - "$SEEDS_DIR/auth_management/07-security_events.sql" - - # === FASE 6: GAMIFICATION USUARIOS (post-profiles) === - "$SEEDS_DIR/gamification_system/04-initialize_user_gamification.sql" - - # === FASE 7: CONTENIDO ADICIONAL === "$SEEDS_DIR/content_management/01-marie-curie-bio.sql" "$SEEDS_DIR/content_management/02-media-files.sql" "$SEEDS_DIR/content_management/03-tags.sql" diff --git a/projects/gamilit/apps/database/seeds/dev/auth_management/01-tenants.sql b/projects/gamilit/apps/database/seeds/dev/auth_management/01-tenants.sql index 05a3f43..5135a2b 100644 --- a/projects/gamilit/apps/database/seeds/dev/auth_management/01-tenants.sql +++ b/projects/gamilit/apps/database/seeds/dev/auth_management/01-tenants.sql @@ -119,37 +119,6 @@ INSERT INTO auth_management.tenants ( }'::jsonb, gamilit.now_mexico(), gamilit.now_mexico() -), --- Tenant 4: Gamilit Production (para usuarios de producción) --- AGREGADO 2025-12-19: Necesario para profiles de producción -( - 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid, - 'Gamilit Production', - 'gamilit-prod', - 'gamilit.com', - NULL, - 'enterprise', - 10000, - 1000, - true, - NULL, - '{ - "theme": "detective", - "language": "es", - "timezone": "America/Mexico_City", - "features": { - "analytics_enabled": true, - "gamification_enabled": true, - "social_features_enabled": true - } - }'::jsonb, - '{ - "description": "Tenant principal de producción", - "environment": "production", - "created_by": "seed_script" - }'::jsonb, - gamilit.now_mexico(), - gamilit.now_mexico() ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, diff --git a/projects/gamilit/apps/database/seeds/prod/auth_management/01-tenants.sql b/projects/gamilit/apps/database/seeds/prod/auth_management/01-tenants.sql index 05a3f43..5135a2b 100644 --- a/projects/gamilit/apps/database/seeds/prod/auth_management/01-tenants.sql +++ b/projects/gamilit/apps/database/seeds/prod/auth_management/01-tenants.sql @@ -119,37 +119,6 @@ INSERT INTO auth_management.tenants ( }'::jsonb, gamilit.now_mexico(), gamilit.now_mexico() -), --- Tenant 4: Gamilit Production (para usuarios de producción) --- AGREGADO 2025-12-19: Necesario para profiles de producción -( - 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid, - 'Gamilit Production', - 'gamilit-prod', - 'gamilit.com', - NULL, - 'enterprise', - 10000, - 1000, - true, - NULL, - '{ - "theme": "detective", - "language": "es", - "timezone": "America/Mexico_City", - "features": { - "analytics_enabled": true, - "gamification_enabled": true, - "social_features_enabled": true - } - }'::jsonb, - '{ - "description": "Tenant principal de producción", - "environment": "production", - "created_by": "seed_script" - }'::jsonb, - gamilit.now_mexico(), - gamilit.now_mexico() ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name,