feat: Sincronizar cambios desde workspace-old

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 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2025-12-19 01:39:38 -06:00
parent 5a0a29412c
commit 43441691cc
7 changed files with 149 additions and 455 deletions

View File

@ -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;
}
/**

View File

@ -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
`;

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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,