ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
951 lines
29 KiB
Markdown
951 lines
29 KiB
Markdown
---
|
|
id: "ET-EDU-001"
|
|
title: "Schema Education Database"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-EDU-001"
|
|
epic: "OQI-002"
|
|
version: "1.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# ET-EDU-001: Modelo de Datos - Schema Education
|
|
|
|
**Versión:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Épica:** OQI-002 - Módulo Educativo
|
|
**Componente:** Database
|
|
|
|
---
|
|
|
|
## Descripción
|
|
|
|
Define el modelo de datos completo para el módulo educativo de Trading Platform, implementado en PostgreSQL 15+ bajo el schema `education`. Incluye todas las entidades necesarias para gestionar cursos, lecciones, progreso de estudiantes, evaluaciones, certificaciones y gamificación.
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ SCHEMA: education │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ categories │────────>│ courses │ │
|
|
│ └──────────────┘ └──────┬───────┘ │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────┐ │
|
|
│ │ modules │ │
|
|
│ └──────┬───────┘ │
|
|
│ │ │
|
|
│ v │
|
|
│ ┌──────────────┐ │
|
|
│ ┌──────────────┐ │ lessons │ │
|
|
│ │ users │────────>└──────┬───────┘ │
|
|
│ │ (auth.users) │ │ │
|
|
│ └──────┬───────┘ │ │
|
|
│ │ v │
|
|
│ │ ┌──────────────┐ │
|
|
│ ├────────────────>│ enrollments │ │
|
|
│ │ └──────────────┘ │
|
|
│ │ │
|
|
│ │ ┌──────────────┐ │
|
|
│ ├────────────────>│ progress │ │
|
|
│ │ └──────────────┘ │
|
|
│ │ │
|
|
│ │ ┌──────────────┐ ┌────────────────┐ │
|
|
│ │ │ quizzes │─────>│quiz_questions │ │
|
|
│ │ └──────┬───────┘ └────────────────┘ │
|
|
│ │ │ │
|
|
│ │ v │
|
|
│ │ ┌──────────────┐ │
|
|
│ └─>│quiz_attempts │ │
|
|
│ └──────────────┘ │
|
|
│ │ │
|
|
│ │ ┌──────────────┐ ┌────────────────┐ │
|
|
│ ├─>│certificates │ │user_achievements│ │
|
|
│ │ └──────────────┘ └────────────────┘ │
|
|
│ │ │
|
|
│ └───────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Especificación Detallada
|
|
|
|
### ENUMs
|
|
|
|
```sql
|
|
-- Nivel de dificultad
|
|
CREATE TYPE education.difficulty_level AS ENUM (
|
|
'beginner',
|
|
'intermediate',
|
|
'advanced',
|
|
'expert'
|
|
);
|
|
|
|
-- Estado de curso
|
|
CREATE TYPE education.course_status AS ENUM (
|
|
'draft',
|
|
'published',
|
|
'archived'
|
|
);
|
|
|
|
-- Estado de enrollment
|
|
CREATE TYPE education.enrollment_status AS ENUM (
|
|
'active',
|
|
'completed',
|
|
'expired',
|
|
'cancelled'
|
|
);
|
|
|
|
-- Tipo de contenido de lección
|
|
CREATE TYPE education.lesson_content_type AS ENUM (
|
|
'video',
|
|
'article',
|
|
'interactive',
|
|
'quiz'
|
|
);
|
|
|
|
-- Tipo de pregunta de quiz
|
|
CREATE TYPE education.question_type AS ENUM (
|
|
'multiple_choice',
|
|
'true_false',
|
|
'multiple_select',
|
|
'fill_blank',
|
|
'code_challenge'
|
|
);
|
|
|
|
-- Tipo de logro/badge
|
|
CREATE TYPE education.achievement_type AS ENUM (
|
|
'course_completion',
|
|
'quiz_perfect_score',
|
|
'streak_milestone',
|
|
'level_up',
|
|
'special_event'
|
|
);
|
|
```
|
|
|
|
### Tabla: categories
|
|
|
|
```sql
|
|
CREATE TABLE education.categories (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Información básica
|
|
name VARCHAR(100) NOT NULL,
|
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
|
description TEXT,
|
|
|
|
-- Jerarquía
|
|
parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL,
|
|
|
|
-- Ordenamiento y visualización
|
|
display_order INTEGER DEFAULT 0,
|
|
icon_url VARCHAR(500),
|
|
color VARCHAR(7), -- Código hex #RRGGBB
|
|
|
|
-- Metadata
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT valid_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$')
|
|
);
|
|
|
|
CREATE INDEX idx_categories_parent ON education.categories(parent_id);
|
|
CREATE INDEX idx_categories_slug ON education.categories(slug);
|
|
CREATE INDEX idx_categories_active ON education.categories(is_active) WHERE is_active = true;
|
|
```
|
|
|
|
### Tabla: courses
|
|
|
|
```sql
|
|
CREATE TABLE education.courses (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Información básica
|
|
title VARCHAR(200) NOT NULL,
|
|
slug VARCHAR(200) NOT NULL UNIQUE,
|
|
short_description VARCHAR(500),
|
|
full_description TEXT,
|
|
|
|
-- Categorización
|
|
category_id UUID NOT NULL REFERENCES education.categories(id) ON DELETE RESTRICT,
|
|
difficulty_level education.difficulty_level NOT NULL DEFAULT 'beginner',
|
|
|
|
-- Contenido
|
|
thumbnail_url VARCHAR(500),
|
|
trailer_url VARCHAR(500), -- Video de presentación
|
|
|
|
-- Metadata educativa
|
|
duration_minutes INTEGER, -- Duración estimada total
|
|
prerequisites TEXT[], -- IDs de cursos prerequisitos
|
|
learning_objectives TEXT[], -- Array de objetivos
|
|
|
|
-- Instructor
|
|
instructor_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
|
instructor_name VARCHAR(200), -- Denormalizado para performance
|
|
|
|
-- Pricing (para futuras features)
|
|
is_free BOOLEAN DEFAULT true,
|
|
price_usd DECIMAL(10,2),
|
|
|
|
-- Gamificación
|
|
xp_reward INTEGER DEFAULT 0, -- XP al completar el curso
|
|
|
|
-- Estado
|
|
status education.course_status DEFAULT 'draft',
|
|
published_at TIMESTAMPTZ,
|
|
|
|
-- Estadísticas (denormalizadas)
|
|
total_modules INTEGER DEFAULT 0,
|
|
total_lessons INTEGER DEFAULT 0,
|
|
total_enrollments INTEGER DEFAULT 0,
|
|
avg_rating DECIMAL(3,2) DEFAULT 0.00,
|
|
total_reviews INTEGER DEFAULT 0,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT valid_rating CHECK (avg_rating >= 0 AND avg_rating <= 5),
|
|
CONSTRAINT valid_price CHECK (price_usd >= 0)
|
|
);
|
|
|
|
CREATE INDEX idx_courses_category ON education.courses(category_id);
|
|
CREATE INDEX idx_courses_slug ON education.courses(slug);
|
|
CREATE INDEX idx_courses_status ON education.courses(status);
|
|
CREATE INDEX idx_courses_difficulty ON education.courses(difficulty_level);
|
|
CREATE INDEX idx_courses_instructor ON education.courses(instructor_id);
|
|
CREATE INDEX idx_courses_published ON education.courses(published_at) WHERE status = 'published';
|
|
```
|
|
|
|
### Tabla: modules
|
|
|
|
```sql
|
|
CREATE TABLE education.modules (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relación con curso
|
|
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE,
|
|
|
|
-- Información básica
|
|
title VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Ordenamiento
|
|
display_order INTEGER NOT NULL DEFAULT 0,
|
|
|
|
-- Metadata
|
|
duration_minutes INTEGER,
|
|
|
|
-- Control de acceso
|
|
is_locked BOOLEAN DEFAULT false, -- Requiere completar módulos anteriores
|
|
unlock_after_module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_course_order UNIQUE(course_id, display_order)
|
|
);
|
|
|
|
CREATE INDEX idx_modules_course ON education.modules(course_id);
|
|
CREATE INDEX idx_modules_order ON education.modules(course_id, display_order);
|
|
```
|
|
|
|
### Tabla: lessons
|
|
|
|
```sql
|
|
CREATE TABLE education.lessons (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relación con módulo
|
|
module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE,
|
|
|
|
-- Información básica
|
|
title VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Tipo de contenido
|
|
content_type education.lesson_content_type NOT NULL DEFAULT 'video',
|
|
|
|
-- Contenido video
|
|
video_url VARCHAR(500), -- URL de Vimeo/S3
|
|
video_duration_seconds INTEGER,
|
|
video_provider VARCHAR(50), -- 'vimeo', 's3', etc.
|
|
video_id VARCHAR(200), -- ID del video en el provider
|
|
|
|
-- Contenido texto/article
|
|
article_content TEXT,
|
|
|
|
-- Recursos adicionales
|
|
attachments JSONB, -- [{name, url, type, size}]
|
|
|
|
-- Ordenamiento
|
|
display_order INTEGER NOT NULL DEFAULT 0,
|
|
|
|
-- Configuración
|
|
is_preview BOOLEAN DEFAULT false, -- Puede verse sin enrollment
|
|
is_mandatory BOOLEAN DEFAULT true, -- Requerido para completar el curso
|
|
|
|
-- Gamificación
|
|
xp_reward INTEGER DEFAULT 10, -- XP al completar la lección
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_module_order UNIQUE(module_id, display_order),
|
|
CONSTRAINT video_fields_required CHECK (
|
|
(content_type != 'video') OR
|
|
(video_url IS NOT NULL AND video_duration_seconds IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_lessons_module ON education.lessons(module_id);
|
|
CREATE INDEX idx_lessons_order ON education.lessons(module_id, display_order);
|
|
CREATE INDEX idx_lessons_type ON education.lessons(content_type);
|
|
CREATE INDEX idx_lessons_preview ON education.lessons(is_preview) WHERE is_preview = true;
|
|
```
|
|
|
|
### Tabla: enrollments
|
|
|
|
```sql
|
|
CREATE TABLE education.enrollments (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT,
|
|
|
|
-- Estado
|
|
status education.enrollment_status DEFAULT 'active',
|
|
|
|
-- Progreso
|
|
progress_percentage DECIMAL(5,2) DEFAULT 0.00,
|
|
completed_lessons INTEGER DEFAULT 0,
|
|
total_lessons INTEGER DEFAULT 0, -- Snapshot del total al enrollarse
|
|
|
|
-- Fechas importantes
|
|
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
|
|
started_at TIMESTAMPTZ, -- Primera lección vista
|
|
completed_at TIMESTAMPTZ,
|
|
expires_at TIMESTAMPTZ, -- Para cursos con límite de tiempo
|
|
|
|
-- Gamificación
|
|
total_xp_earned INTEGER DEFAULT 0,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_user_course UNIQUE(user_id, course_id),
|
|
CONSTRAINT valid_progress CHECK (progress_percentage >= 0 AND progress_percentage <= 100),
|
|
CONSTRAINT valid_completion CHECK (
|
|
(status != 'completed') OR
|
|
(completed_at IS NOT NULL AND progress_percentage = 100)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_enrollments_user ON education.enrollments(user_id);
|
|
CREATE INDEX idx_enrollments_course ON education.enrollments(course_id);
|
|
CREATE INDEX idx_enrollments_status ON education.enrollments(status);
|
|
CREATE INDEX idx_enrollments_user_active ON education.enrollments(user_id, status)
|
|
WHERE status = 'active';
|
|
```
|
|
|
|
### Tabla: progress
|
|
|
|
```sql
|
|
CREATE TABLE education.progress (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE,
|
|
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE,
|
|
|
|
-- Estado
|
|
is_completed BOOLEAN DEFAULT false,
|
|
|
|
-- Progreso de video
|
|
last_position_seconds INTEGER DEFAULT 0,
|
|
total_watch_time_seconds INTEGER DEFAULT 0, -- Tiempo total visto
|
|
watch_percentage DECIMAL(5,2) DEFAULT 0.00,
|
|
|
|
-- Tracking
|
|
first_viewed_at TIMESTAMPTZ,
|
|
last_viewed_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_user_lesson UNIQUE(user_id, lesson_id),
|
|
CONSTRAINT valid_watch_percentage CHECK (watch_percentage >= 0 AND watch_percentage <= 100),
|
|
CONSTRAINT completion_requires_date CHECK (
|
|
(NOT is_completed) OR (completed_at IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_progress_user ON education.progress(user_id);
|
|
CREATE INDEX idx_progress_lesson ON education.progress(lesson_id);
|
|
CREATE INDEX idx_progress_enrollment ON education.progress(enrollment_id);
|
|
CREATE INDEX idx_progress_completed ON education.progress(is_completed) WHERE is_completed = true;
|
|
CREATE INDEX idx_progress_user_enrollment ON education.progress(user_id, enrollment_id);
|
|
```
|
|
|
|
### Tabla: quizzes
|
|
|
|
```sql
|
|
CREATE TABLE education.quizzes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relación (puede estar asociado a módulo o lección)
|
|
module_id UUID REFERENCES education.modules(id) ON DELETE CASCADE,
|
|
lesson_id UUID REFERENCES education.lessons(id) ON DELETE CASCADE,
|
|
|
|
-- Información básica
|
|
title VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Configuración
|
|
passing_score_percentage INTEGER DEFAULT 70, -- % mínimo para aprobar
|
|
max_attempts INTEGER, -- NULL = intentos ilimitados
|
|
time_limit_minutes INTEGER, -- NULL = sin límite de tiempo
|
|
|
|
-- Opciones
|
|
shuffle_questions BOOLEAN DEFAULT true,
|
|
shuffle_answers BOOLEAN DEFAULT true,
|
|
show_correct_answers BOOLEAN DEFAULT true, -- Después de completar
|
|
|
|
-- Gamificación
|
|
xp_reward INTEGER DEFAULT 50,
|
|
xp_perfect_score_bonus INTEGER DEFAULT 20,
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT true,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT valid_passing_score CHECK (passing_score_percentage > 0 AND passing_score_percentage <= 100),
|
|
CONSTRAINT quiz_association CHECK (
|
|
(module_id IS NOT NULL AND lesson_id IS NULL) OR
|
|
(module_id IS NULL AND lesson_id IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_quizzes_module ON education.quizzes(module_id);
|
|
CREATE INDEX idx_quizzes_lesson ON education.quizzes(lesson_id);
|
|
CREATE INDEX idx_quizzes_active ON education.quizzes(is_active) WHERE is_active = true;
|
|
```
|
|
|
|
### Tabla: quiz_questions
|
|
|
|
```sql
|
|
CREATE TABLE education.quiz_questions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relación
|
|
quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE,
|
|
|
|
-- Pregunta
|
|
question_text TEXT NOT NULL,
|
|
question_type education.question_type NOT NULL DEFAULT 'multiple_choice',
|
|
|
|
-- Opciones de respuesta (para multiple_choice, true_false, multiple_select)
|
|
options JSONB, -- [{id, text, isCorrect}]
|
|
|
|
-- Respuesta correcta (para fill_blank, code_challenge)
|
|
correct_answer TEXT,
|
|
|
|
-- Explicación
|
|
explanation TEXT, -- Mostrar después de responder
|
|
|
|
-- Recursos adicionales
|
|
image_url VARCHAR(500),
|
|
code_snippet TEXT,
|
|
|
|
-- Puntuación
|
|
points INTEGER DEFAULT 1,
|
|
|
|
-- Ordenamiento
|
|
display_order INTEGER DEFAULT 0,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT valid_options CHECK (
|
|
(question_type NOT IN ('multiple_choice', 'true_false', 'multiple_select')) OR
|
|
(options IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_quiz_questions_quiz ON education.quiz_questions(quiz_id);
|
|
CREATE INDEX idx_quiz_questions_order ON education.quiz_questions(quiz_id, display_order);
|
|
```
|
|
|
|
### Tabla: quiz_attempts
|
|
|
|
```sql
|
|
CREATE TABLE education.quiz_attempts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE RESTRICT,
|
|
enrollment_id UUID REFERENCES education.enrollments(id) ON DELETE SET NULL,
|
|
|
|
-- Estado del intento
|
|
is_completed BOOLEAN DEFAULT false,
|
|
is_passed BOOLEAN DEFAULT false,
|
|
|
|
-- Respuestas del usuario
|
|
user_answers JSONB, -- [{questionId, answer, isCorrect, points}]
|
|
|
|
-- Puntuación
|
|
score_points INTEGER DEFAULT 0,
|
|
max_points INTEGER DEFAULT 0,
|
|
score_percentage DECIMAL(5,2) DEFAULT 0.00,
|
|
|
|
-- Tiempo
|
|
started_at TIMESTAMPTZ DEFAULT NOW(),
|
|
completed_at TIMESTAMPTZ,
|
|
time_taken_seconds INTEGER,
|
|
|
|
-- XP ganado
|
|
xp_earned INTEGER DEFAULT 0,
|
|
|
|
-- Metadata
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT valid_score_percentage CHECK (score_percentage >= 0 AND score_percentage <= 100)
|
|
);
|
|
|
|
CREATE INDEX idx_quiz_attempts_user ON education.quiz_attempts(user_id);
|
|
CREATE INDEX idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id);
|
|
CREATE INDEX idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id);
|
|
CREATE INDEX idx_quiz_attempts_user_quiz ON education.quiz_attempts(user_id, quiz_id);
|
|
CREATE INDEX idx_quiz_attempts_completed ON education.quiz_attempts(is_completed, completed_at);
|
|
```
|
|
|
|
### Tabla: certificates
|
|
|
|
```sql
|
|
CREATE TABLE education.certificates (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relaciones
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT,
|
|
enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE RESTRICT,
|
|
|
|
-- Información del certificado
|
|
certificate_number VARCHAR(50) NOT NULL UNIQUE, -- OQI-CERT-XXXX-YYYY
|
|
|
|
-- Datos del certificado (snapshot)
|
|
user_name VARCHAR(200) NOT NULL,
|
|
course_title VARCHAR(200) NOT NULL,
|
|
instructor_name VARCHAR(200),
|
|
completion_date DATE NOT NULL,
|
|
|
|
-- Metadata de logro
|
|
final_score DECIMAL(5,2), -- Promedio de quizzes
|
|
total_xp_earned INTEGER,
|
|
|
|
-- URL del PDF generado
|
|
certificate_url VARCHAR(500),
|
|
|
|
-- Verificación
|
|
verification_code VARCHAR(100) UNIQUE, -- Para verificar autenticidad
|
|
is_verified BOOLEAN DEFAULT true,
|
|
|
|
-- Metadata
|
|
issued_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_user_course_cert UNIQUE(user_id, course_id)
|
|
);
|
|
|
|
CREATE INDEX idx_certificates_user ON education.certificates(user_id);
|
|
CREATE INDEX idx_certificates_course ON education.certificates(course_id);
|
|
CREATE INDEX idx_certificates_number ON education.certificates(certificate_number);
|
|
CREATE INDEX idx_certificates_verification ON education.certificates(verification_code);
|
|
```
|
|
|
|
### Tabla: user_achievements
|
|
|
|
```sql
|
|
CREATE TABLE education.user_achievements (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Relación
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Tipo de logro
|
|
achievement_type education.achievement_type NOT NULL,
|
|
|
|
-- Información del logro
|
|
title VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
badge_icon_url VARCHAR(500),
|
|
|
|
-- Metadata del logro
|
|
metadata JSONB, -- Información específica del logro
|
|
|
|
-- Referencias
|
|
course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL,
|
|
quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL,
|
|
|
|
-- XP bonus por el logro
|
|
xp_bonus INTEGER DEFAULT 0,
|
|
|
|
-- Metadata
|
|
earned_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_user_achievements_user ON education.user_achievements(user_id);
|
|
CREATE INDEX idx_user_achievements_type ON education.user_achievements(achievement_type);
|
|
CREATE INDEX idx_user_achievements_earned ON education.user_achievements(earned_at DESC);
|
|
CREATE INDEX idx_user_achievements_course ON education.user_achievements(course_id);
|
|
```
|
|
|
|
---
|
|
|
|
## Triggers y Funciones
|
|
|
|
### Trigger: Actualizar timestamp updated_at
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION education.update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Aplicar a todas las tablas relevantes
|
|
CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON education.categories
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
|
|
CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON education.courses
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
|
|
CREATE TRIGGER update_modules_updated_at BEFORE UPDATE ON education.modules
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
|
|
CREATE TRIGGER update_lessons_updated_at BEFORE UPDATE ON education.lessons
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
|
|
CREATE TRIGGER update_enrollments_updated_at BEFORE UPDATE ON education.enrollments
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
|
|
CREATE TRIGGER update_progress_updated_at BEFORE UPDATE ON education.progress
|
|
FOR EACH ROW EXECUTE FUNCTION education.update_updated_at_column();
|
|
```
|
|
|
|
### Trigger: Actualizar estadísticas de enrollment
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION education.update_enrollment_progress()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
v_total_lessons INTEGER;
|
|
v_completed_lessons INTEGER;
|
|
v_progress_percentage DECIMAL(5,2);
|
|
BEGIN
|
|
-- Obtener totales
|
|
SELECT COUNT(*)
|
|
INTO v_total_lessons
|
|
FROM education.lessons l
|
|
JOIN education.modules m ON l.module_id = m.id
|
|
JOIN education.courses c ON m.course_id = c.id
|
|
WHERE c.id = (
|
|
SELECT course_id FROM education.enrollments WHERE id = NEW.enrollment_id
|
|
) AND l.is_mandatory = true;
|
|
|
|
-- Obtener completadas
|
|
SELECT COUNT(*)
|
|
INTO v_completed_lessons
|
|
FROM education.progress
|
|
WHERE enrollment_id = NEW.enrollment_id
|
|
AND is_completed = true;
|
|
|
|
-- Calcular progreso
|
|
v_progress_percentage := (v_completed_lessons::DECIMAL / NULLIF(v_total_lessons, 0)::DECIMAL) * 100;
|
|
|
|
-- Actualizar enrollment
|
|
UPDATE education.enrollments
|
|
SET
|
|
progress_percentage = COALESCE(v_progress_percentage, 0),
|
|
completed_lessons = v_completed_lessons,
|
|
total_lessons = v_total_lessons,
|
|
updated_at = NOW()
|
|
WHERE id = NEW.enrollment_id;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER update_enrollment_on_progress
|
|
AFTER INSERT OR UPDATE ON education.progress
|
|
FOR EACH ROW
|
|
WHEN (NEW.is_completed = true)
|
|
EXECUTE FUNCTION education.update_enrollment_progress();
|
|
```
|
|
|
|
### Trigger: Completar enrollment automáticamente
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION education.auto_complete_enrollment()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.progress_percentage >= 100 AND NEW.status = 'active' THEN
|
|
NEW.status := 'completed';
|
|
NEW.completed_at := NOW();
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER auto_complete_enrollment_trigger
|
|
BEFORE UPDATE ON education.enrollments
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION education.auto_complete_enrollment();
|
|
```
|
|
|
|
### Trigger: Generar número de certificado
|
|
|
|
```sql
|
|
CREATE OR REPLACE FUNCTION education.generate_certificate_number()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
v_year INTEGER;
|
|
v_sequence INTEGER;
|
|
BEGIN
|
|
v_year := EXTRACT(YEAR FROM NOW());
|
|
|
|
-- Obtener siguiente número de secuencia para el año
|
|
SELECT COALESCE(MAX(
|
|
CAST(SUBSTRING(certificate_number FROM 14) AS INTEGER)
|
|
), 0) + 1
|
|
INTO v_sequence
|
|
FROM education.certificates
|
|
WHERE certificate_number LIKE 'OQI-CERT-' || v_year || '-%';
|
|
|
|
-- Generar número de certificado: OQI-CERT-2025-00001
|
|
NEW.certificate_number := FORMAT('OQI-CERT-%s-%s',
|
|
v_year,
|
|
LPAD(v_sequence::TEXT, 5, '0')
|
|
);
|
|
|
|
-- Generar código de verificación
|
|
NEW.verification_code := UPPER(
|
|
SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 16)
|
|
);
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER generate_certificate_number_trigger
|
|
BEFORE INSERT ON education.certificates
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION education.generate_certificate_number();
|
|
```
|
|
|
|
---
|
|
|
|
## Vistas
|
|
|
|
### Vista: Cursos con estadísticas
|
|
|
|
```sql
|
|
CREATE OR REPLACE VIEW education.v_courses_with_stats AS
|
|
SELECT
|
|
c.*,
|
|
cat.name as category_name,
|
|
cat.slug as category_slug,
|
|
COUNT(DISTINCT m.id) as modules_count,
|
|
COUNT(DISTINCT l.id) as lessons_count,
|
|
SUM(l.video_duration_seconds) as total_duration_seconds,
|
|
COUNT(DISTINCT e.id) as enrollments_count,
|
|
COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions_count
|
|
FROM education.courses c
|
|
LEFT JOIN education.categories cat ON c.category_id = cat.id
|
|
LEFT JOIN education.modules m ON c.id = m.course_id
|
|
LEFT JOIN education.lessons l ON m.id = l.module_id
|
|
LEFT JOIN education.enrollments e ON c.id = e.course_id
|
|
GROUP BY c.id, cat.name, cat.slug;
|
|
```
|
|
|
|
### Vista: Progreso del usuario
|
|
|
|
```sql
|
|
CREATE OR REPLACE VIEW education.v_user_course_progress AS
|
|
SELECT
|
|
e.user_id,
|
|
e.course_id,
|
|
c.title as course_title,
|
|
c.thumbnail_url,
|
|
e.status as enrollment_status,
|
|
e.progress_percentage,
|
|
e.enrolled_at,
|
|
e.completed_at,
|
|
e.total_xp_earned,
|
|
COUNT(DISTINCT p.id) as lessons_viewed,
|
|
COUNT(DISTINCT CASE WHEN p.is_completed THEN p.id END) as lessons_completed
|
|
FROM education.enrollments e
|
|
JOIN education.courses c ON e.course_id = c.id
|
|
LEFT JOIN education.progress p ON e.id = p.enrollment_id
|
|
GROUP BY e.id, e.user_id, e.course_id, c.title, c.thumbnail_url;
|
|
```
|
|
|
|
---
|
|
|
|
## Interfaces/Tipos
|
|
|
|
Interfaces TypeScript generadas automáticamente desde el schema usando Prisma o similar.
|
|
|
|
---
|
|
|
|
## Configuración
|
|
|
|
### Variables de Entorno
|
|
|
|
```bash
|
|
# PostgreSQL
|
|
DATABASE_URL=postgresql://user:password@localhost:5432/trading
|
|
DATABASE_SCHEMA=education
|
|
DATABASE_POOL_MIN=2
|
|
DATABASE_POOL_MAX=10
|
|
|
|
# Performance
|
|
DB_STATEMENT_TIMEOUT=30000
|
|
DB_QUERY_TIMEOUT=10000
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencias
|
|
|
|
- PostgreSQL 15+
|
|
- Extension: `uuid-ossp` o built-in `gen_random_uuid()`
|
|
- Extension: `pg_trgm` (para búsqueda full-text)
|
|
|
|
---
|
|
|
|
## Consideraciones de Seguridad
|
|
|
|
### Row Level Security (RLS)
|
|
|
|
```sql
|
|
-- Habilitar RLS en tablas sensibles
|
|
ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE education.progress ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Política: Los usuarios solo ven sus propios enrollments
|
|
CREATE POLICY user_own_enrollments ON education.enrollments
|
|
FOR ALL
|
|
USING (user_id = current_setting('app.user_id')::UUID);
|
|
|
|
-- Política: Los usuarios solo ven su propio progreso
|
|
CREATE POLICY user_own_progress ON education.progress
|
|
FOR ALL
|
|
USING (user_id = current_setting('app.user_id')::UUID);
|
|
|
|
-- Política: Admin puede ver todo
|
|
CREATE POLICY admin_all_access ON education.enrollments
|
|
FOR ALL
|
|
USING (current_setting('app.user_role') = 'admin');
|
|
```
|
|
|
|
### Validaciones
|
|
|
|
1. **Integridad referencial**: Todos los FKs con políticas ON DELETE apropiadas
|
|
2. **Constraints**: Validación de rangos, formatos, estados
|
|
3. **Triggers**: Validación de lógica de negocio
|
|
4. **Indexes**: Performance en queries frecuentes
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Estrategia
|
|
|
|
1. **Schema Tests**
|
|
- Validar creación de todas las tablas
|
|
- Verificar constraints
|
|
- Probar triggers
|
|
|
|
2. **Data Integrity Tests**
|
|
- Inserción de datos válidos
|
|
- Rechazo de datos inválidos
|
|
- Cascadas de eliminación
|
|
|
|
3. **Performance Tests**
|
|
- Queries con índices
|
|
- Queries complejas con JOINs
|
|
- Estadísticas de enrollments
|
|
|
|
### Ejemplo de Test
|
|
|
|
```sql
|
|
-- Test: Completar lección actualiza progreso del enrollment
|
|
BEGIN;
|
|
|
|
INSERT INTO education.progress (user_id, lesson_id, enrollment_id, is_completed, completed_at)
|
|
VALUES ('user-uuid', 'lesson-uuid', 'enrollment-uuid', true, NOW());
|
|
|
|
SELECT
|
|
progress_percentage,
|
|
completed_lessons
|
|
FROM education.enrollments
|
|
WHERE id = 'enrollment-uuid';
|
|
|
|
-- Debe reflejar el progreso actualizado
|
|
|
|
ROLLBACK;
|
|
```
|
|
|
|
---
|
|
|
|
## Migraciones
|
|
|
|
### Versionado
|
|
|
|
- Usar herramienta de migración (Prisma Migrate, TypeORM, etc.)
|
|
- Versionado semántico para cambios de schema
|
|
- Rollback plan para cada migración
|
|
|
|
### Orden de Creación
|
|
|
|
1. ENUMs
|
|
2. Tablas base (categories, users)
|
|
3. Tablas dependientes (courses, modules, lessons)
|
|
4. Tablas de relación (enrollments, progress)
|
|
5. Tablas de evaluación (quizzes, quiz_questions, quiz_attempts)
|
|
6. Tablas de logros (certificates, user_achievements)
|
|
7. Triggers y funciones
|
|
8. Vistas
|
|
9. Índices adicionales
|
|
10. RLS policies
|
|
|
|
---
|
|
|
|
**Fin de Especificación ET-EDU-001**
|