# 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 OrbiQuant IA, 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/orbiquant
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**
