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

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-ossp o built-in gen_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

  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

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