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