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>
29 KiB
29 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-EDU-001 | Schema Education Database | Specification | Done | RF-EDU-001 | OQI-002 | 1.0 | 2025-12-05 | 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
-- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
# 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-osspo built-ingen_random_uuid() - Extension:
pg_trgm(para búsqueda full-text)
Consideraciones de Seguridad
Row Level Security (RLS)
-- 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
- Integridad referencial: Todos los FKs con políticas ON DELETE apropiadas
- Constraints: Validación de rangos, formatos, estados
- Triggers: Validación de lógica de negocio
- Indexes: Performance en queries frecuentes
Testing
Estrategia
-
Schema Tests
- Validar creación de todas las tablas
- Verificar constraints
- Probar triggers
-
Data Integrity Tests
- Inserción de datos válidos
- Rechazo de datos inválidos
- Cascadas de eliminación
-
Performance Tests
- Queries con índices
- Queries complejas con JOINs
- Estadísticas de enrollments
Ejemplo de Test
-- 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
- ENUMs
- Tablas base (categories, users)
- Tablas dependientes (courses, modules, lessons)
- Tablas de relación (enrollments, progress)
- Tablas de evaluación (quizzes, quiz_questions, quiz_attempts)
- Tablas de logros (certificates, user_achievements)
- Triggers y funciones
- Vistas
- Índices adicionales
- RLS policies
Fin de Especificación ET-EDU-001