trading-platform/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-001-database.md
rckrdmrd c1b5081208 feat(ml): Complete FASE 11 - BTCUSD update and comprehensive documentation alignment
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>
2026-01-07 09:31:29 -06:00

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