diff --git a/ddl/schemas/education/tables/001_categories.sql b/ddl/schemas/education/tables/001_categories.sql new file mode 100644 index 0000000..ff0273b --- /dev/null +++ b/ddl/schemas/education/tables/001_categories.sql @@ -0,0 +1,236 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: categories +-- DESCRIPTION: Categorias de cursos educativos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS education; + +-- Grant usage +GRANT USAGE ON SCHEMA education TO trading_app; +GRANT USAGE ON SCHEMA education TO trading_readonly; + +-- ============================================================================ +-- ENUMS COMPARTIDOS DEL SCHEMA EDUCATION +-- ============================================================================ + +-- Enum para nivel de dificultad +DO $$ BEGIN + CREATE TYPE education.difficulty_level AS ENUM ( + 'beginner', -- Principiante + 'intermediate', -- Intermedio + 'advanced', -- Avanzado + 'expert' -- Experto + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de publicacion +DO $$ BEGIN + CREATE TYPE education.publish_status AS ENUM ( + 'draft', -- Borrador + 'review', -- En revision + 'published', -- Publicado + 'archived', -- Archivado + 'scheduled' -- Programado para publicar + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para tipo de contenido +DO $$ BEGIN + CREATE TYPE education.content_type AS ENUM ( + 'video', -- Video + 'article', -- Articulo/texto + 'quiz', -- Cuestionario + 'interactive', -- Contenido interactivo + 'download', -- Material descargable + 'live', -- Sesion en vivo + 'assignment' -- Tarea/ejercicio + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================ +-- TABLA: categories +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS education.categories ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Jerarquia + parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, + level INTEGER NOT NULL DEFAULT 0, -- Nivel en el arbol (0 = raiz) + path TEXT, -- Path materializado: "parent_id/id" + + -- Informacion basica + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + short_description VARCHAR(255), + + -- Visual + icon VARCHAR(50), -- Nombre del icono (ej: 'chart-line') + color VARCHAR(7), -- Color hex (ej: '#FFD700') + image_url TEXT, + banner_url TEXT, + + -- SEO + meta_title VARCHAR(100), + meta_description VARCHAR(255), + meta_keywords VARCHAR(255)[], + + -- Estado + status education.publish_status NOT NULL DEFAULT 'draft', + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + + -- Orden y organizacion + display_order INTEGER NOT NULL DEFAULT 0, + + -- Estadisticas (cache) + course_count INTEGER NOT NULL DEFAULT 0, + total_enrollments INTEGER NOT NULL DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT categories_unique_slug_per_tenant UNIQUE (tenant_id, slug), + CONSTRAINT categories_no_self_parent CHECK (id != parent_id) +); + +COMMENT ON TABLE education.categories IS +'Categorias jerarquicas para organizar cursos educativos'; + +COMMENT ON COLUMN education.categories.path IS +'Path materializado para queries de jerarquia eficientes'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_categories_tenant + ON education.categories(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_categories_parent + ON education.categories(parent_id); + +CREATE INDEX IF NOT EXISTS idx_categories_slug + ON education.categories(tenant_id, slug); + +CREATE INDEX IF NOT EXISTS idx_categories_status + ON education.categories(status) + WHERE status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_categories_featured + ON education.categories(tenant_id, is_featured, display_order) + WHERE is_featured = TRUE AND is_visible = TRUE; + +CREATE INDEX IF NOT EXISTS idx_categories_path + ON education.categories(path text_pattern_ops); + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION education.update_education_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS category_updated_at ON education.categories; +CREATE TRIGGER category_updated_at + BEFORE UPDATE ON education.categories + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para mantener path materializado +CREATE OR REPLACE FUNCTION education.update_category_path() +RETURNS TRIGGER AS $$ +DECLARE + v_parent_path TEXT; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.path := NEW.id::TEXT; + NEW.level := 0; + ELSE + SELECT path, level INTO v_parent_path + FROM education.categories + WHERE id = NEW.parent_id; + + NEW.path := v_parent_path || '/' || NEW.id::TEXT; + NEW.level := (SELECT level + 1 FROM education.categories WHERE id = NEW.parent_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS category_path_update ON education.categories; +CREATE TRIGGER category_path_update + BEFORE INSERT OR UPDATE OF parent_id ON education.categories + FOR EACH ROW + EXECUTE FUNCTION education.update_category_path(); + +-- Funcion para obtener subcategorias +CREATE OR REPLACE FUNCTION education.get_subcategories(p_category_id UUID) +RETURNS TABLE ( + id UUID, + name VARCHAR(100), + slug VARCHAR(100), + level INTEGER, + course_count INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT c.id, c.name, c.slug, c.level, c.course_count + FROM education.categories c + WHERE c.path LIKE (SELECT path FROM education.categories WHERE id = p_category_id) || '/%' + ORDER BY c.level, c.display_order; +END; +$$ LANGUAGE plpgsql; + +-- Vista de categorias publicadas +CREATE OR REPLACE VIEW education.v_published_categories AS +SELECT + id, + tenant_id, + parent_id, + name, + slug, + description, + icon, + color, + image_url, + course_count, + display_order, + level +FROM education.categories +WHERE status = 'published' + AND is_visible = TRUE +ORDER BY level, display_order, name; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.categories ENABLE ROW LEVEL SECURITY; + +CREATE POLICY categories_tenant_isolation ON education.categories + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.categories TO trading_app; +GRANT SELECT ON education.categories TO trading_readonly; +GRANT SELECT ON education.v_published_categories TO trading_app; +GRANT EXECUTE ON FUNCTION education.get_subcategories TO trading_app; diff --git a/ddl/schemas/education/tables/002_courses.sql b/ddl/schemas/education/tables/002_courses.sql new file mode 100644 index 0000000..532d187 --- /dev/null +++ b/ddl/schemas/education/tables/002_courses.sql @@ -0,0 +1,269 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: courses +-- DESCRIPTION: Cursos de trading y educacion financiera +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de curso +DO $$ BEGIN + CREATE TYPE education.course_type AS ENUM ( + 'course', -- Curso completo + 'workshop', -- Taller practico + 'masterclass', -- Masterclass + 'webinar', -- Webinar grabado + 'bootcamp', -- Bootcamp intensivo + 'certification' -- Programa de certificacion + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para modelo de acceso +DO $$ BEGIN + CREATE TYPE education.access_model AS ENUM ( + 'free', -- Gratis + 'freemium', -- Gratis con contenido premium + 'paid', -- De pago + 'subscription', -- Requiere suscripcion VIP + 'bundle' -- Parte de un bundle + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Cursos +CREATE TABLE IF NOT EXISTS education.courses ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + category_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, + + -- Informacion basica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + subtitle VARCHAR(255), + description TEXT, + short_description VARCHAR(500), + + -- Clasificacion + type education.course_type NOT NULL DEFAULT 'course', + difficulty education.difficulty_level NOT NULL DEFAULT 'beginner', + + -- Instructor/Autor + instructor_id UUID REFERENCES users.users(id), + instructor_name VARCHAR(100), + instructor_bio TEXT, + instructor_avatar_url TEXT, + + -- Media + thumbnail_url TEXT, + banner_url TEXT, + preview_video_url TEXT, + trailer_url TEXT, + + -- Contenido + objectives JSONB DEFAULT '[]'::JSONB, -- Array de objetivos de aprendizaje + requirements JSONB DEFAULT '[]'::JSONB, -- Prerrequisitos + target_audience JSONB DEFAULT '[]'::JSONB, -- Publico objetivo + syllabus JSONB DEFAULT '[]'::JSONB, -- Temario resumido + + -- Duracion y estructura + estimated_duration_minutes INTEGER, -- Duracion total estimada + total_lessons INTEGER NOT NULL DEFAULT 0, + total_quizzes INTEGER NOT NULL DEFAULT 0, + total_modules INTEGER NOT NULL DEFAULT 0, + + -- Acceso y precio + access_model education.access_model NOT NULL DEFAULT 'free', + price DECIMAL(10, 2) DEFAULT 0, + currency VARCHAR(3) DEFAULT 'USD', + discount_price DECIMAL(10, 2), + discount_ends_at TIMESTAMPTZ, + + -- Requisitos de suscripcion + required_vip_tier VARCHAR(50), -- NULL = no requiere VIP + credits_cost INTEGER DEFAULT 0, -- Costo en creditos del wallet + + -- Estado + status education.publish_status NOT NULL DEFAULT 'draft', + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + is_new BOOLEAN NOT NULL DEFAULT TRUE, + is_bestseller BOOLEAN NOT NULL DEFAULT FALSE, + + -- SEO + meta_title VARCHAR(100), + meta_description VARCHAR(255), + meta_keywords VARCHAR(255)[], + + -- Estadisticas (cache) + enrollment_count INTEGER NOT NULL DEFAULT 0, + completion_count INTEGER NOT NULL DEFAULT 0, + average_rating DECIMAL(3, 2) DEFAULT 0, + review_count INTEGER NOT NULL DEFAULT 0, + view_count INTEGER NOT NULL DEFAULT 0, + + -- Certificacion + has_certificate BOOLEAN NOT NULL DEFAULT FALSE, + certificate_template_id UUID, + passing_score INTEGER DEFAULT 70, -- % minimo para certificado + + -- Gamificacion + xp_reward INTEGER DEFAULT 0, -- XP al completar + badge_id UUID, -- Badge al completar + + -- Configuracion + allow_reviews BOOLEAN NOT NULL DEFAULT TRUE, + allow_discussions BOOLEAN NOT NULL DEFAULT TRUE, + enforce_sequence BOOLEAN NOT NULL DEFAULT FALSE, -- Forzar orden de lecciones + + -- Metadata + tags VARCHAR(50)[], + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + last_content_update TIMESTAMPTZ, + + -- Constraints + CONSTRAINT courses_unique_slug_per_tenant UNIQUE (tenant_id, slug), + CONSTRAINT courses_price_check CHECK (price >= 0), + CONSTRAINT courses_rating_check CHECK (average_rating >= 0 AND average_rating <= 5) +); + +COMMENT ON TABLE education.courses IS +'Cursos de trading y educacion financiera'; + +COMMENT ON COLUMN education.courses.enforce_sequence IS +'Si TRUE, el usuario debe completar lecciones en orden'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_courses_tenant + ON education.courses(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_courses_category + ON education.courses(category_id); + +CREATE INDEX IF NOT EXISTS idx_courses_instructor + ON education.courses(instructor_id); + +CREATE INDEX IF NOT EXISTS idx_courses_slug + ON education.courses(tenant_id, slug); + +CREATE INDEX IF NOT EXISTS idx_courses_status + ON education.courses(status) + WHERE status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_courses_featured + ON education.courses(tenant_id, is_featured) + WHERE is_featured = TRUE AND status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_courses_difficulty + ON education.courses(difficulty); + +CREATE INDEX IF NOT EXISTS idx_courses_access + ON education.courses(access_model); + +CREATE INDEX IF NOT EXISTS idx_courses_rating + ON education.courses(average_rating DESC) + WHERE status = 'published'; + +CREATE INDEX IF NOT EXISTS idx_courses_popular + ON education.courses(enrollment_count DESC) + WHERE status = 'published'; + +-- GIN index para tags +CREATE INDEX IF NOT EXISTS idx_courses_tags_gin + ON education.courses USING GIN (tags); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS course_updated_at ON education.courses; +CREATE TRIGGER course_updated_at + BEFORE UPDATE ON education.courses + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar conteo en categoria +CREATE OR REPLACE FUNCTION education.update_category_course_count() +RETURNS TRIGGER AS $$ +BEGIN + -- Decrementar categoria anterior + IF OLD IS NOT NULL AND OLD.category_id IS NOT NULL THEN + UPDATE education.categories + SET course_count = course_count - 1 + WHERE id = OLD.category_id; + END IF; + + -- Incrementar categoria nueva + IF NEW IS NOT NULL AND NEW.category_id IS NOT NULL AND NEW.status = 'published' THEN + UPDATE education.categories + SET course_count = course_count + 1 + WHERE id = NEW.category_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS course_category_count ON education.courses; +CREATE TRIGGER course_category_count + AFTER INSERT OR UPDATE OF category_id, status OR DELETE ON education.courses + FOR EACH ROW + EXECUTE FUNCTION education.update_category_course_count(); + +-- Vista de cursos publicados +CREATE OR REPLACE VIEW education.v_published_courses AS +SELECT + c.id, + c.tenant_id, + c.category_id, + cat.name AS category_name, + c.title, + c.slug, + c.subtitle, + c.short_description, + c.type, + c.difficulty, + c.instructor_name, + c.thumbnail_url, + c.estimated_duration_minutes, + c.total_lessons, + c.access_model, + c.price, + c.discount_price, + c.is_featured, + c.is_new, + c.is_bestseller, + c.enrollment_count, + c.average_rating, + c.review_count, + c.has_certificate, + c.tags +FROM education.courses c +LEFT JOIN education.categories cat ON c.category_id = cat.id +WHERE c.status = 'published' +ORDER BY c.is_featured DESC, c.enrollment_count DESC; + +-- Vista de cursos populares +CREATE OR REPLACE VIEW education.v_popular_courses AS +SELECT * +FROM education.v_published_courses +ORDER BY enrollment_count DESC +LIMIT 20; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.courses ENABLE ROW LEVEL SECURITY; + +CREATE POLICY courses_tenant_isolation ON education.courses + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.courses TO trading_app; +GRANT SELECT ON education.courses TO trading_readonly; +GRANT SELECT ON education.v_published_courses TO trading_app; +GRANT SELECT ON education.v_popular_courses TO trading_app; diff --git a/ddl/schemas/education/tables/003_modules.sql b/ddl/schemas/education/tables/003_modules.sql new file mode 100644 index 0000000..8d98a81 --- /dev/null +++ b/ddl/schemas/education/tables/003_modules.sql @@ -0,0 +1,134 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: modules +-- DESCRIPTION: Modulos/secciones de un curso +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Modulos +CREATE TABLE IF NOT EXISTS education.modules ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Informacion basica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + description TEXT, + + -- Orden y estructura + sequence_number INTEGER NOT NULL DEFAULT 0, + + -- Estado + status education.publish_status NOT NULL DEFAULT 'draft', + is_preview BOOLEAN NOT NULL DEFAULT FALSE, -- Disponible como preview gratuito + is_locked BOOLEAN NOT NULL DEFAULT FALSE, -- Bloqueado hasta cumplir requisitos + + -- Requisitos para desbloquear + unlock_requirements JSONB DEFAULT '{}'::JSONB, -- { "modules": [], "min_score": 70 } + + -- Duracion + estimated_duration_minutes INTEGER, + + -- Estadisticas (cache) + lesson_count INTEGER NOT NULL DEFAULT 0, + quiz_count INTEGER NOT NULL DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT modules_unique_sequence UNIQUE (course_id, sequence_number), + CONSTRAINT modules_unique_slug UNIQUE (course_id, slug) +); + +COMMENT ON TABLE education.modules IS +'Modulos o secciones que organizan las lecciones de un curso'; + +COMMENT ON COLUMN education.modules.is_preview IS +'Si TRUE, el modulo esta disponible como preview gratuito'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_modules_course + ON education.modules(course_id, sequence_number); + +CREATE INDEX IF NOT EXISTS idx_modules_tenant + ON education.modules(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_modules_status + ON education.modules(status); + +CREATE INDEX IF NOT EXISTS idx_modules_preview + ON education.modules(course_id, is_preview) + WHERE is_preview = TRUE; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS module_updated_at ON education.modules; +CREATE TRIGGER module_updated_at + BEFORE UPDATE ON education.modules + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar conteo en curso +CREATE OR REPLACE FUNCTION education.update_course_module_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE education.courses + SET total_modules = total_modules + 1, + last_content_update = NOW() + WHERE id = NEW.course_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE education.courses + SET total_modules = total_modules - 1, + last_content_update = NOW() + WHERE id = OLD.course_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS module_course_count ON education.modules; +CREATE TRIGGER module_course_count + AFTER INSERT OR DELETE ON education.modules + FOR EACH ROW + EXECUTE FUNCTION education.update_course_module_count(); + +-- Vista de modulos con lecciones +CREATE OR REPLACE VIEW education.v_course_modules AS +SELECT + m.id, + m.course_id, + m.title, + m.slug, + m.description, + m.sequence_number, + m.status, + m.is_preview, + m.is_locked, + m.estimated_duration_minutes, + m.lesson_count, + m.quiz_count +FROM education.modules m +WHERE m.status = 'published' +ORDER BY m.course_id, m.sequence_number; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.modules ENABLE ROW LEVEL SECURITY; + +CREATE POLICY modules_tenant_isolation ON education.modules + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.modules TO trading_app; +GRANT SELECT ON education.modules TO trading_readonly; +GRANT SELECT ON education.v_course_modules TO trading_app; diff --git a/ddl/schemas/education/tables/004_lessons.sql b/ddl/schemas/education/tables/004_lessons.sql new file mode 100644 index 0000000..829d8fe --- /dev/null +++ b/ddl/schemas/education/tables/004_lessons.sql @@ -0,0 +1,185 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: lessons +-- DESCRIPTION: Lecciones individuales de un modulo +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Lecciones +CREATE TABLE IF NOT EXISTS education.lessons ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Informacion basica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + description TEXT, + + -- Tipo de contenido + content_type education.content_type NOT NULL DEFAULT 'video', + + -- Orden + sequence_number INTEGER NOT NULL DEFAULT 0, + + -- Contenido principal + content TEXT, -- Contenido HTML/Markdown + content_json JSONB, -- Contenido estructurado + + -- Video + video_url TEXT, + video_provider VARCHAR(50), -- 'youtube', 'vimeo', 'bunny', 'self' + video_id VARCHAR(100), + video_duration_seconds INTEGER, + video_thumbnail_url TEXT, + + -- Recursos + resources JSONB DEFAULT '[]'::JSONB, -- Array de archivos descargables + -- [{ "name": "PDF", "url": "...", "type": "pdf", "size": 1024 }] + + -- Interactividad + has_quiz BOOLEAN NOT NULL DEFAULT FALSE, + quiz_id UUID, -- Quiz asociado a esta leccion + + -- Estado + status education.publish_status NOT NULL DEFAULT 'draft', + is_preview BOOLEAN NOT NULL DEFAULT FALSE, + is_mandatory BOOLEAN NOT NULL DEFAULT TRUE, -- Obligatoria para completar curso + + -- Duracion + estimated_duration_minutes INTEGER, + + -- Requisitos + requires_previous_completion BOOLEAN NOT NULL DEFAULT FALSE, + + -- Gamificacion + xp_reward INTEGER DEFAULT 10, + + -- AI Generated content + is_ai_generated BOOLEAN NOT NULL DEFAULT FALSE, + ai_generation_prompt TEXT, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT lessons_unique_sequence UNIQUE (module_id, sequence_number), + CONSTRAINT lessons_unique_slug UNIQUE (module_id, slug) +); + +COMMENT ON TABLE education.lessons IS +'Lecciones individuales que componen un modulo del curso'; + +COMMENT ON COLUMN education.lessons.content_type IS +'Tipo de contenido: video, article, quiz, interactive, download, live, assignment'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_lessons_module + ON education.lessons(module_id, sequence_number); + +CREATE INDEX IF NOT EXISTS idx_lessons_course + ON education.lessons(course_id); + +CREATE INDEX IF NOT EXISTS idx_lessons_tenant + ON education.lessons(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_lessons_status + ON education.lessons(status); + +CREATE INDEX IF NOT EXISTS idx_lessons_content_type + ON education.lessons(content_type); + +CREATE INDEX IF NOT EXISTS idx_lessons_preview + ON education.lessons(course_id, is_preview) + WHERE is_preview = TRUE; + +CREATE INDEX IF NOT EXISTS idx_lessons_with_quiz + ON education.lessons(quiz_id) + WHERE quiz_id IS NOT NULL; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS lesson_updated_at ON education.lessons; +CREATE TRIGGER lesson_updated_at + BEFORE UPDATE ON education.lessons + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar conteos +CREATE OR REPLACE FUNCTION education.update_lesson_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + -- Actualizar modulo + UPDATE education.modules + SET lesson_count = lesson_count + 1 + WHERE id = NEW.module_id; + + -- Actualizar curso + UPDATE education.courses + SET total_lessons = total_lessons + 1, + last_content_update = NOW() + WHERE id = NEW.course_id; + + ELSIF TG_OP = 'DELETE' THEN + UPDATE education.modules + SET lesson_count = lesson_count - 1 + WHERE id = OLD.module_id; + + UPDATE education.courses + SET total_lessons = total_lessons - 1, + last_content_update = NOW() + WHERE id = OLD.course_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS lesson_counts ON education.lessons; +CREATE TRIGGER lesson_counts + AFTER INSERT OR DELETE ON education.lessons + FOR EACH ROW + EXECUTE FUNCTION education.update_lesson_counts(); + +-- Vista de lecciones de un curso +CREATE OR REPLACE VIEW education.v_course_lessons AS +SELECT + l.id, + l.module_id, + l.course_id, + m.title AS module_title, + m.sequence_number AS module_sequence, + l.title, + l.slug, + l.content_type, + l.sequence_number, + l.video_duration_seconds, + l.estimated_duration_minutes, + l.is_preview, + l.is_mandatory, + l.has_quiz, + l.status +FROM education.lessons l +JOIN education.modules m ON l.module_id = m.id +WHERE l.status = 'published' +ORDER BY m.sequence_number, l.sequence_number; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.lessons ENABLE ROW LEVEL SECURITY; + +CREATE POLICY lessons_tenant_isolation ON education.lessons + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.lessons TO trading_app; +GRANT SELECT ON education.lessons TO trading_readonly; +GRANT SELECT ON education.v_course_lessons TO trading_app; diff --git a/ddl/schemas/education/tables/005_quizzes.sql b/ddl/schemas/education/tables/005_quizzes.sql new file mode 100644 index 0000000..267a467 --- /dev/null +++ b/ddl/schemas/education/tables/005_quizzes.sql @@ -0,0 +1,219 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: quizzes +-- DESCRIPTION: Cuestionarios y evaluaciones +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de quiz +DO $$ BEGIN + CREATE TYPE education.quiz_type AS ENUM ( + 'practice', -- Quiz de practica (sin limite) + 'graded', -- Quiz calificado + 'final_exam', -- Examen final + 'diagnostic', -- Evaluacion diagnostica + 'survey' -- Encuesta (sin respuestas correctas) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Quizzes +CREATE TABLE IF NOT EXISTS education.quizzes ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Informacion basica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + description TEXT, + instructions TEXT, + + -- Tipo + type education.quiz_type NOT NULL DEFAULT 'practice', + + -- Configuracion + time_limit_minutes INTEGER, -- NULL = sin limite + passing_score INTEGER NOT NULL DEFAULT 70, -- Porcentaje minimo para aprobar + max_attempts INTEGER, -- NULL = ilimitado + shuffle_questions BOOLEAN NOT NULL DEFAULT TRUE, + shuffle_answers BOOLEAN NOT NULL DEFAULT TRUE, + show_correct_answers BOOLEAN NOT NULL DEFAULT TRUE, + show_explanations BOOLEAN NOT NULL DEFAULT TRUE, + immediate_feedback BOOLEAN NOT NULL DEFAULT FALSE, + + -- Preguntas + question_count INTEGER NOT NULL DEFAULT 0, + questions_to_show INTEGER, -- NULL = mostrar todas + points_per_question INTEGER DEFAULT 1, + + -- Estado + status education.publish_status NOT NULL DEFAULT 'draft', + + -- Disponibilidad + available_from TIMESTAMPTZ, + available_until TIMESTAMPTZ, + + -- Requisitos + requires_lesson_completion BOOLEAN NOT NULL DEFAULT FALSE, + required_lessons UUID[], -- Lecciones que deben completarse antes + + -- Gamificacion + xp_reward INTEGER DEFAULT 50, + badge_on_perfect_score UUID, + + -- Estadisticas (cache) + attempt_count INTEGER NOT NULL DEFAULT 0, + average_score DECIMAL(5, 2) DEFAULT 0, + pass_rate DECIMAL(5, 2) DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT quizzes_unique_slug UNIQUE (course_id, slug), + CONSTRAINT quizzes_passing_score_check CHECK (passing_score BETWEEN 0 AND 100) +); + +COMMENT ON TABLE education.quizzes IS +'Cuestionarios y evaluaciones del sistema educativo'; + +COMMENT ON COLUMN education.quizzes.questions_to_show IS +'Si es menor que question_count, se seleccionan aleatoriamente'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_quizzes_course + ON education.quizzes(course_id); + +CREATE INDEX IF NOT EXISTS idx_quizzes_module + ON education.quizzes(module_id) + WHERE module_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_quizzes_lesson + ON education.quizzes(lesson_id) + WHERE lesson_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_quizzes_tenant + ON education.quizzes(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_quizzes_type + ON education.quizzes(type); + +CREATE INDEX IF NOT EXISTS idx_quizzes_status + ON education.quizzes(status); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS quiz_updated_at ON education.quizzes; +CREATE TRIGGER quiz_updated_at + BEFORE UPDATE ON education.quizzes + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar quiz_count en modulo y curso +CREATE OR REPLACE FUNCTION education.update_quiz_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.module_id IS NOT NULL THEN + UPDATE education.modules + SET quiz_count = quiz_count + 1 + WHERE id = NEW.module_id; + END IF; + + UPDATE education.courses + SET total_quizzes = total_quizzes + 1 + WHERE id = NEW.course_id; + + ELSIF TG_OP = 'DELETE' THEN + IF OLD.module_id IS NOT NULL THEN + UPDATE education.modules + SET quiz_count = quiz_count - 1 + WHERE id = OLD.module_id; + END IF; + + UPDATE education.courses + SET total_quizzes = total_quizzes - 1 + WHERE id = OLD.course_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS quiz_counts ON education.quizzes; +CREATE TRIGGER quiz_counts + AFTER INSERT OR DELETE ON education.quizzes + FOR EACH ROW + EXECUTE FUNCTION education.update_quiz_counts(); + +-- Actualizar lesson.has_quiz cuando se asocia un quiz +CREATE OR REPLACE FUNCTION education.update_lesson_has_quiz() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + IF NEW.lesson_id IS NOT NULL THEN + UPDATE education.lessons + SET has_quiz = TRUE, quiz_id = NEW.id + WHERE id = NEW.lesson_id; + END IF; + END IF; + + IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.lesson_id != NEW.lesson_id) THEN + IF OLD.lesson_id IS NOT NULL THEN + UPDATE education.lessons + SET has_quiz = FALSE, quiz_id = NULL + WHERE id = OLD.lesson_id; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS quiz_lesson_link ON education.quizzes; +CREATE TRIGGER quiz_lesson_link + AFTER INSERT OR UPDATE OF lesson_id OR DELETE ON education.quizzes + FOR EACH ROW + EXECUTE FUNCTION education.update_lesson_has_quiz(); + +-- Vista de quizzes de un curso +CREATE OR REPLACE VIEW education.v_course_quizzes AS +SELECT + q.id, + q.course_id, + q.module_id, + q.lesson_id, + q.title, + q.type, + q.time_limit_minutes, + q.passing_score, + q.max_attempts, + q.question_count, + q.average_score, + q.pass_rate, + q.status +FROM education.quizzes q +WHERE q.status = 'published' +ORDER BY q.course_id, q.created_at; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.quizzes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY quizzes_tenant_isolation ON education.quizzes + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.quizzes TO trading_app; +GRANT SELECT ON education.quizzes TO trading_readonly; +GRANT SELECT ON education.v_course_quizzes TO trading_app; diff --git a/ddl/schemas/education/tables/006_quiz_questions.sql b/ddl/schemas/education/tables/006_quiz_questions.sql new file mode 100644 index 0000000..b236587 --- /dev/null +++ b/ddl/schemas/education/tables/006_quiz_questions.sql @@ -0,0 +1,265 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: quiz_questions +-- DESCRIPTION: Preguntas de cuestionarios +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de pregunta +DO $$ BEGIN + CREATE TYPE education.question_type AS ENUM ( + 'single_choice', -- Una respuesta correcta + 'multiple_choice', -- Multiples respuestas correctas + 'true_false', -- Verdadero/Falso + 'fill_blank', -- Completar espacio en blanco + 'matching', -- Emparejar columnas + 'ordering', -- Ordenar elementos + 'short_answer', -- Respuesta corta (texto) + 'essay' -- Respuesta larga (evaluacion manual) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Preguntas +CREATE TABLE IF NOT EXISTS education.quiz_questions ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Pregunta + type education.question_type NOT NULL DEFAULT 'single_choice', + question_text TEXT NOT NULL, + question_html TEXT, -- Version con formato + question_image_url TEXT, + + -- Opciones de respuesta (para choice questions) + -- Array de objetos: [{ "id": "a", "text": "...", "is_correct": true, "explanation": "..." }] + options JSONB DEFAULT '[]'::JSONB, + + -- Respuesta correcta (para otros tipos) + correct_answer TEXT, -- Para fill_blank, short_answer + correct_answers TEXT[], -- Para multiple answers + answer_pattern VARCHAR(255), -- Regex para validar respuesta + + -- Para matching questions + -- [{ "left": "A", "right": "1" }, { "left": "B", "right": "2" }] + matching_pairs JSONB, + + -- Para ordering questions + correct_order TEXT[], + + -- Explicacion + explanation TEXT, + explanation_html TEXT, + explanation_video_url TEXT, + + -- Configuracion + points INTEGER NOT NULL DEFAULT 1, + partial_credit BOOLEAN NOT NULL DEFAULT FALSE, -- Credito parcial en multiple choice + case_sensitive BOOLEAN NOT NULL DEFAULT FALSE, -- Para respuestas de texto + + -- Dificultad + difficulty education.difficulty_level DEFAULT 'intermediate', + + -- Orden + sequence_number INTEGER NOT NULL DEFAULT 0, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Tags para filtrado + tags VARCHAR(50)[], + topic VARCHAR(100), + + -- Estadisticas (cache) + attempt_count INTEGER NOT NULL DEFAULT 0, + correct_count INTEGER NOT NULL DEFAULT 0, + average_time_seconds INTEGER, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT quiz_questions_points_check CHECK (points > 0) +); + +COMMENT ON TABLE education.quiz_questions IS +'Preguntas individuales que componen un quiz'; + +COMMENT ON COLUMN education.quiz_questions.options IS +'Array de opciones para preguntas de seleccion: [{"id":"a","text":"...","is_correct":true}]'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_quiz_questions_quiz + ON education.quiz_questions(quiz_id, sequence_number); + +CREATE INDEX IF NOT EXISTS idx_quiz_questions_tenant + ON education.quiz_questions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_quiz_questions_type + ON education.quiz_questions(type); + +CREATE INDEX IF NOT EXISTS idx_quiz_questions_difficulty + ON education.quiz_questions(difficulty); + +CREATE INDEX IF NOT EXISTS idx_quiz_questions_active + ON education.quiz_questions(quiz_id, is_active) + WHERE is_active = TRUE; + +-- GIN index para tags +CREATE INDEX IF NOT EXISTS idx_quiz_questions_tags_gin + ON education.quiz_questions USING GIN (tags); + +-- GIN index para options (buscar por contenido) +CREATE INDEX IF NOT EXISTS idx_quiz_questions_options_gin + ON education.quiz_questions USING GIN (options); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS quiz_question_updated_at ON education.quiz_questions; +CREATE TRIGGER quiz_question_updated_at + BEFORE UPDATE ON education.quiz_questions + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar question_count en quiz +CREATE OR REPLACE FUNCTION education.update_question_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE education.quizzes + SET question_count = question_count + 1 + WHERE id = NEW.quiz_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE education.quizzes + SET question_count = question_count - 1 + WHERE id = OLD.quiz_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS question_count ON education.quiz_questions; +CREATE TRIGGER question_count + AFTER INSERT OR DELETE ON education.quiz_questions + FOR EACH ROW + EXECUTE FUNCTION education.update_question_count(); + +-- Funcion para validar respuesta +CREATE OR REPLACE FUNCTION education.validate_answer( + p_question_id UUID, + p_answer TEXT +) +RETURNS TABLE ( + is_correct BOOLEAN, + points_earned INTEGER, + correct_answer TEXT, + explanation TEXT +) AS $$ +DECLARE + v_question RECORD; + v_is_correct BOOLEAN := FALSE; + v_points INTEGER := 0; +BEGIN + SELECT * INTO v_question + FROM education.quiz_questions + WHERE id = p_question_id; + + IF v_question IS NULL THEN + RETURN; + END IF; + + CASE v_question.type + WHEN 'single_choice' THEN + -- Verificar si la opcion seleccionada es correcta + SELECT (opt->>'is_correct')::BOOLEAN INTO v_is_correct + FROM jsonb_array_elements(v_question.options) AS opt + WHERE opt->>'id' = p_answer; + + WHEN 'true_false' THEN + v_is_correct := LOWER(p_answer) = LOWER(v_question.correct_answer); + + WHEN 'fill_blank', 'short_answer' THEN + IF v_question.case_sensitive THEN + v_is_correct := p_answer = v_question.correct_answer; + ELSE + v_is_correct := LOWER(p_answer) = LOWER(v_question.correct_answer); + END IF; + + -- Verificar patron regex si existe + IF NOT v_is_correct AND v_question.answer_pattern IS NOT NULL THEN + v_is_correct := p_answer ~ v_question.answer_pattern; + END IF; + + ELSE + -- Para tipos complejos, retornar NULL (requiere evaluacion especial) + v_is_correct := NULL; + END CASE; + + IF v_is_correct THEN + v_points := v_question.points; + END IF; + + RETURN QUERY SELECT + v_is_correct, + v_points, + v_question.correct_answer, + v_question.explanation; +END; +$$ LANGUAGE plpgsql; + +-- Vista de preguntas de un quiz (sin respuestas correctas para estudiantes) +CREATE OR REPLACE VIEW education.v_quiz_questions_student AS +SELECT + q.id, + q.quiz_id, + q.type, + q.question_text, + q.question_html, + q.question_image_url, + -- Remover is_correct de las opciones + (SELECT jsonb_agg( + jsonb_build_object( + 'id', opt->>'id', + 'text', opt->>'text' + ) + ) FROM jsonb_array_elements(q.options) AS opt) AS options, + q.points, + q.sequence_number, + q.difficulty +FROM education.quiz_questions q +WHERE q.is_active = TRUE +ORDER BY q.quiz_id, q.sequence_number; + +-- Vista de preguntas con estadisticas (para instructores) +CREATE OR REPLACE VIEW education.v_quiz_questions_admin AS +SELECT + q.*, + CASE WHEN q.attempt_count > 0 + THEN (q.correct_count::DECIMAL / q.attempt_count * 100) + ELSE 0 + END AS success_rate +FROM education.quiz_questions q +ORDER BY q.quiz_id, q.sequence_number; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.quiz_questions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY quiz_questions_tenant_isolation ON education.quiz_questions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON education.quiz_questions TO trading_app; +GRANT SELECT ON education.quiz_questions TO trading_readonly; +GRANT SELECT ON education.v_quiz_questions_student TO trading_app; +GRANT SELECT ON education.v_quiz_questions_admin TO trading_app; +GRANT EXECUTE ON FUNCTION education.validate_answer TO trading_app; diff --git a/ddl/schemas/education/tables/007_enrollments.sql b/ddl/schemas/education/tables/007_enrollments.sql new file mode 100644 index 0000000..774ca41 --- /dev/null +++ b/ddl/schemas/education/tables/007_enrollments.sql @@ -0,0 +1,296 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: enrollments +-- DESCRIPTION: Inscripciones de usuarios a cursos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para estado de inscripcion +DO $$ BEGIN + CREATE TYPE education.enrollment_status AS ENUM ( + 'active', -- Inscripcion activa + 'completed', -- Curso completado + 'expired', -- Acceso expirado + 'cancelled', -- Cancelada por usuario + 'refunded', -- Reembolsada + 'suspended' -- Suspendida por admin + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para tipo de acceso +DO $$ BEGIN + CREATE TYPE education.enrollment_type AS ENUM ( + 'free', -- Acceso gratuito + 'purchased', -- Comprado individualmente + 'subscription', -- Via suscripcion VIP + 'gift', -- Regalado + 'promotional', -- Promocional/Codigo + 'bundle', -- Parte de un bundle + 'admin_granted' -- Otorgado por admin + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Inscripciones +CREATE TABLE IF NOT EXISTS education.enrollments ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Tipo y estado + type education.enrollment_type NOT NULL DEFAULT 'free', + status education.enrollment_status NOT NULL DEFAULT 'active', + + -- Progreso general + progress_percent DECIMAL(5, 2) NOT NULL DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100), + lessons_completed INTEGER NOT NULL DEFAULT 0, + quizzes_completed INTEGER NOT NULL DEFAULT 0, + quizzes_passed INTEGER NOT NULL DEFAULT 0, + + -- Tiempo + total_time_spent_minutes INTEGER NOT NULL DEFAULT 0, + last_activity_at TIMESTAMPTZ, + last_lesson_id UUID, + last_position_seconds INTEGER, -- Posicion en el ultimo video + + -- Pago (si aplica) + payment_id UUID, + amount_paid DECIMAL(10, 2), + currency VARCHAR(3) DEFAULT 'USD', + credits_used INTEGER DEFAULT 0, + + -- Acceso + access_starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + access_ends_at TIMESTAMPTZ, -- NULL = acceso permanente + + -- Completado + completed_at TIMESTAMPTZ, + completion_time_days INTEGER, -- Dias desde inscripcion hasta completar + + -- Certificado + certificate_issued BOOLEAN NOT NULL DEFAULT FALSE, + certificate_id UUID, + certificate_issued_at TIMESTAMPTZ, + + -- Calificacion final + final_score DECIMAL(5, 2), + final_grade VARCHAR(2), -- 'A', 'B', 'C', 'D', 'F' + + -- Gamificacion + xp_earned INTEGER NOT NULL DEFAULT 0, + badges_earned UUID[], + + -- Codigo promocional usado + promo_code VARCHAR(50), + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + notes TEXT, -- Notas del admin + + -- Timestamps + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT enrollments_unique_user_course UNIQUE (user_id, course_id) +); + +COMMENT ON TABLE education.enrollments IS +'Inscripciones de usuarios a cursos con tracking de progreso'; + +COMMENT ON COLUMN education.enrollments.access_ends_at IS +'NULL indica acceso permanente al curso'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_enrollments_user + ON education.enrollments(user_id, status); + +CREATE INDEX IF NOT EXISTS idx_enrollments_course + ON education.enrollments(course_id, status); + +CREATE INDEX IF NOT EXISTS idx_enrollments_tenant + ON education.enrollments(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_enrollments_status + ON education.enrollments(status); + +CREATE INDEX IF NOT EXISTS idx_enrollments_active + ON education.enrollments(user_id, course_id) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_enrollments_completed + ON education.enrollments(course_id, completed_at DESC) + WHERE status = 'completed'; + +CREATE INDEX IF NOT EXISTS idx_enrollments_progress + ON education.enrollments(course_id, progress_percent DESC) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_enrollments_last_activity + ON education.enrollments(last_activity_at DESC) + WHERE status = 'active'; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS enrollment_updated_at ON education.enrollments; +CREATE TRIGGER enrollment_updated_at + BEFORE UPDATE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar estadisticas del curso +CREATE OR REPLACE FUNCTION education.update_course_enrollment_stats() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE education.courses + SET enrollment_count = enrollment_count + 1 + WHERE id = NEW.course_id; + + ELSIF TG_OP = 'DELETE' THEN + UPDATE education.courses + SET enrollment_count = enrollment_count - 1 + WHERE id = OLD.course_id; + + ELSIF TG_OP = 'UPDATE' THEN + -- Si cambio a completado + IF NEW.status = 'completed' AND OLD.status != 'completed' THEN + UPDATE education.courses + SET completion_count = completion_count + 1 + WHERE id = NEW.course_id; + -- Si cambio de completado a otro estado + ELSIF OLD.status = 'completed' AND NEW.status != 'completed' THEN + UPDATE education.courses + SET completion_count = completion_count - 1 + WHERE id = NEW.course_id; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS enrollment_stats ON education.enrollments; +CREATE TRIGGER enrollment_stats + AFTER INSERT OR UPDATE OF status OR DELETE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_course_enrollment_stats(); + +-- Funcion para calcular progreso +CREATE OR REPLACE FUNCTION education.calculate_enrollment_progress(p_enrollment_id UUID) +RETURNS DECIMAL(5, 2) AS $$ +DECLARE + v_enrollment RECORD; + v_total_lessons INTEGER; + v_completed_lessons INTEGER; + v_progress DECIMAL(5, 2); +BEGIN + SELECT * INTO v_enrollment + FROM education.enrollments + WHERE id = p_enrollment_id; + + IF v_enrollment IS NULL THEN + RETURN 0; + END IF; + + -- Contar lecciones totales del curso + SELECT COUNT(*) INTO v_total_lessons + FROM education.lessons + WHERE course_id = v_enrollment.course_id + AND status = 'published' + AND is_mandatory = TRUE; + + -- Contar lecciones completadas + SELECT COUNT(*) INTO v_completed_lessons + FROM education.lesson_progress lp + WHERE lp.enrollment_id = p_enrollment_id + AND lp.is_completed = TRUE; + + IF v_total_lessons = 0 THEN + RETURN 0; + END IF; + + v_progress := (v_completed_lessons::DECIMAL / v_total_lessons * 100); + + -- Actualizar enrollment + UPDATE education.enrollments + SET progress_percent = v_progress, + lessons_completed = v_completed_lessons, + status = CASE WHEN v_progress >= 100 THEN 'completed' ELSE status END, + completed_at = CASE WHEN v_progress >= 100 AND completed_at IS NULL THEN NOW() ELSE completed_at END + WHERE id = p_enrollment_id; + + RETURN v_progress; +END; +$$ LANGUAGE plpgsql; + +-- Vista de mis cursos (para estudiante) +CREATE OR REPLACE VIEW education.v_my_courses AS +SELECT + e.id AS enrollment_id, + e.user_id, + e.course_id, + c.title, + c.slug, + c.thumbnail_url, + c.difficulty, + c.total_lessons, + e.status, + e.progress_percent, + e.lessons_completed, + e.last_activity_at, + e.enrolled_at, + e.completed_at, + e.certificate_issued +FROM education.enrollments e +JOIN education.courses c ON e.course_id = c.id +WHERE e.status NOT IN ('cancelled', 'refunded') +ORDER BY e.last_activity_at DESC NULLS LAST; + +-- Vista de estudiantes de un curso (para instructor) +CREATE OR REPLACE VIEW education.v_course_students AS +SELECT + e.id AS enrollment_id, + e.user_id, + u.email, + u.first_name, + u.last_name, + e.course_id, + e.status, + e.progress_percent, + e.lessons_completed, + e.quizzes_passed, + e.final_score, + e.total_time_spent_minutes, + e.enrolled_at, + e.last_activity_at, + e.completed_at +FROM education.enrollments e +JOIN users.users u ON e.user_id = u.id +ORDER BY e.enrolled_at DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY enrollments_tenant_isolation ON education.enrollments + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Los usuarios solo ven sus propias inscripciones +CREATE POLICY enrollments_user_isolation ON education.enrollments + FOR SELECT + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON education.enrollments TO trading_app; +GRANT SELECT ON education.enrollments TO trading_readonly; +GRANT SELECT ON education.v_my_courses TO trading_app; +GRANT SELECT ON education.v_course_students TO trading_app; +GRANT EXECUTE ON FUNCTION education.calculate_enrollment_progress TO trading_app; diff --git a/ddl/schemas/education/tables/008_lesson_progress.sql b/ddl/schemas/education/tables/008_lesson_progress.sql new file mode 100644 index 0000000..aad927e --- /dev/null +++ b/ddl/schemas/education/tables/008_lesson_progress.sql @@ -0,0 +1,257 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: lesson_progress +-- DESCRIPTION: Progreso de usuarios por leccion +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Progreso por Leccion +CREATE TABLE IF NOT EXISTS education.lesson_progress ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Estado + is_started BOOLEAN NOT NULL DEFAULT FALSE, + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + + -- Progreso de video + video_progress_percent DECIMAL(5, 2) DEFAULT 0, + video_position_seconds INTEGER DEFAULT 0, -- Ultima posicion del video + video_watched_seconds INTEGER DEFAULT 0, -- Total segundos vistos + video_completed BOOLEAN NOT NULL DEFAULT FALSE, + + -- Tiempo + time_spent_minutes INTEGER NOT NULL DEFAULT 0, + visit_count INTEGER NOT NULL DEFAULT 1, + + -- Notas del usuario + user_notes TEXT, + bookmarked BOOLEAN NOT NULL DEFAULT FALSE, + bookmark_position_seconds INTEGER, + + -- Quiz asociado (si existe) + quiz_attempted BOOLEAN NOT NULL DEFAULT FALSE, + quiz_passed BOOLEAN NOT NULL DEFAULT FALSE, + quiz_score DECIMAL(5, 2), + + -- Recursos descargados + resources_downloaded JSONB DEFAULT '[]'::JSONB, + + -- XP ganado + xp_earned INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT lesson_progress_unique UNIQUE (enrollment_id, lesson_id), + CONSTRAINT lesson_progress_video_check CHECK (video_progress_percent BETWEEN 0 AND 100) +); + +COMMENT ON TABLE education.lesson_progress IS +'Tracking detallado del progreso de cada usuario en cada leccion'; + +COMMENT ON COLUMN education.lesson_progress.video_position_seconds IS +'Ultima posicion de reproduccion del video (para continuar donde lo dejo)'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_lesson_progress_enrollment + ON education.lesson_progress(enrollment_id); + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_lesson + ON education.lesson_progress(lesson_id); + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_user + ON education.lesson_progress(user_id); + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_tenant + ON education.lesson_progress(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_completed + ON education.lesson_progress(enrollment_id, is_completed) + WHERE is_completed = TRUE; + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_in_progress + ON education.lesson_progress(enrollment_id, lesson_id) + WHERE is_started = TRUE AND is_completed = FALSE; + +CREATE INDEX IF NOT EXISTS idx_lesson_progress_bookmarked + ON education.lesson_progress(user_id, bookmarked) + WHERE bookmarked = TRUE; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS lesson_progress_updated_at ON education.lesson_progress; +CREATE TRIGGER lesson_progress_updated_at + BEFORE UPDATE ON education.lesson_progress + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar started_at y completed_at +CREATE OR REPLACE FUNCTION education.update_lesson_progress_timestamps() +RETURNS TRIGGER AS $$ +BEGIN + -- Marcar inicio + IF NEW.is_started = TRUE AND OLD.is_started = FALSE THEN + NEW.started_at = NOW(); + END IF; + + -- Marcar completado + IF NEW.is_completed = TRUE AND OLD.is_completed = FALSE THEN + NEW.completed_at = NOW(); + + -- Otorgar XP si no se ha ganado + IF NEW.xp_earned = 0 THEN + SELECT xp_reward INTO NEW.xp_earned + FROM education.lessons + WHERE id = NEW.lesson_id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS lesson_progress_timestamps ON education.lesson_progress; +CREATE TRIGGER lesson_progress_timestamps + BEFORE UPDATE OF is_started, is_completed ON education.lesson_progress + FOR EACH ROW + EXECUTE FUNCTION education.update_lesson_progress_timestamps(); + +-- Trigger para recalcular progreso del enrollment +CREATE OR REPLACE FUNCTION education.recalculate_enrollment_on_lesson() +RETURNS TRIGGER AS $$ +BEGIN + -- Recalcular progreso del enrollment + PERFORM education.calculate_enrollment_progress(NEW.enrollment_id); + + -- Actualizar tiempo total + UPDATE education.enrollments + SET total_time_spent_minutes = ( + SELECT COALESCE(SUM(time_spent_minutes), 0) + FROM education.lesson_progress + WHERE enrollment_id = NEW.enrollment_id + ), + last_activity_at = NOW(), + last_lesson_id = NEW.lesson_id, + last_position_seconds = NEW.video_position_seconds + WHERE id = NEW.enrollment_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS lesson_progress_enrollment_update ON education.lesson_progress; +CREATE TRIGGER lesson_progress_enrollment_update + AFTER INSERT OR UPDATE OF is_completed, time_spent_minutes ON education.lesson_progress + FOR EACH ROW + EXECUTE FUNCTION education.recalculate_enrollment_on_lesson(); + +-- Funcion para registrar actividad en leccion +CREATE OR REPLACE FUNCTION education.record_lesson_activity( + p_enrollment_id UUID, + p_lesson_id UUID, + p_video_position INTEGER DEFAULT NULL, + p_time_spent_minutes INTEGER DEFAULT 0 +) +RETURNS UUID AS $$ +DECLARE + v_progress_id UUID; + v_user_id UUID; + v_tenant_id UUID; + v_lesson RECORD; +BEGIN + -- Obtener datos del enrollment + SELECT user_id, tenant_id INTO v_user_id, v_tenant_id + FROM education.enrollments + WHERE id = p_enrollment_id; + + -- Obtener datos de la leccion + SELECT * INTO v_lesson + FROM education.lessons + WHERE id = p_lesson_id; + + -- Insertar o actualizar progreso + INSERT INTO education.lesson_progress ( + enrollment_id, lesson_id, user_id, tenant_id, + is_started, video_position_seconds, time_spent_minutes, + last_accessed_at + ) VALUES ( + p_enrollment_id, p_lesson_id, v_user_id, v_tenant_id, + TRUE, COALESCE(p_video_position, 0), p_time_spent_minutes, + NOW() + ) + ON CONFLICT (enrollment_id, lesson_id) + DO UPDATE SET + video_position_seconds = COALESCE(p_video_position, education.lesson_progress.video_position_seconds), + time_spent_minutes = education.lesson_progress.time_spent_minutes + p_time_spent_minutes, + visit_count = education.lesson_progress.visit_count + 1, + last_accessed_at = NOW() + RETURNING id INTO v_progress_id; + + -- Verificar si se completo el video + IF p_video_position IS NOT NULL AND v_lesson.video_duration_seconds IS NOT NULL THEN + IF p_video_position >= (v_lesson.video_duration_seconds * 0.9) THEN + UPDATE education.lesson_progress + SET video_completed = TRUE, + video_progress_percent = 100, + is_completed = CASE WHEN NOT v_lesson.has_quiz THEN TRUE ELSE is_completed END + WHERE id = v_progress_id; + ELSE + UPDATE education.lesson_progress + SET video_progress_percent = (p_video_position::DECIMAL / v_lesson.video_duration_seconds * 100) + WHERE id = v_progress_id; + END IF; + END IF; + + RETURN v_progress_id; +END; +$$ LANGUAGE plpgsql; + +-- Vista de progreso detallado de un enrollment +CREATE OR REPLACE VIEW education.v_enrollment_progress AS +SELECT + lp.enrollment_id, + lp.lesson_id, + l.title AS lesson_title, + l.content_type, + l.sequence_number, + m.id AS module_id, + m.title AS module_title, + m.sequence_number AS module_sequence, + lp.is_started, + lp.is_completed, + lp.video_progress_percent, + lp.time_spent_minutes, + lp.quiz_passed, + lp.completed_at +FROM education.lesson_progress lp +JOIN education.lessons l ON lp.lesson_id = l.id +JOIN education.modules m ON l.module_id = m.id +ORDER BY m.sequence_number, l.sequence_number; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.lesson_progress ENABLE ROW LEVEL SECURITY; + +CREATE POLICY lesson_progress_tenant_isolation ON education.lesson_progress + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY lesson_progress_user_isolation ON education.lesson_progress + FOR SELECT + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON education.lesson_progress TO trading_app; +GRANT SELECT ON education.lesson_progress TO trading_readonly; +GRANT SELECT ON education.v_enrollment_progress TO trading_app; +GRANT EXECUTE ON FUNCTION education.record_lesson_activity TO trading_app; diff --git a/ddl/schemas/education/tables/009_quiz_attempts.sql b/ddl/schemas/education/tables/009_quiz_attempts.sql new file mode 100644 index 0000000..d1d3c05 --- /dev/null +++ b/ddl/schemas/education/tables/009_quiz_attempts.sql @@ -0,0 +1,377 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: quiz_attempts +-- DESCRIPTION: Intentos de quizzes por usuarios +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para estado del intento +DO $$ BEGIN + CREATE TYPE education.attempt_status AS ENUM ( + 'in_progress', -- En progreso + 'submitted', -- Enviado, pendiente calificacion + 'graded', -- Calificado + 'abandoned', -- Abandonado (tiempo expirado) + 'voided' -- Anulado por admin + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Intentos de Quiz +CREATE TABLE IF NOT EXISTS education.quiz_attempts ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Numero de intento + attempt_number INTEGER NOT NULL DEFAULT 1, + + -- Estado + status education.attempt_status NOT NULL DEFAULT 'in_progress', + + -- Tiempo + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + submitted_at TIMESTAMPTZ, + graded_at TIMESTAMPTZ, + time_limit_at TIMESTAMPTZ, -- Cuando expira el tiempo + time_spent_seconds INTEGER, + + -- Preguntas + questions_shown JSONB NOT NULL DEFAULT '[]'::JSONB, -- IDs de preguntas mostradas (si shuffle) + questions_total INTEGER NOT NULL DEFAULT 0, + questions_answered INTEGER NOT NULL DEFAULT 0, + + -- Respuestas + -- [{ "question_id": "...", "answer": "...", "is_correct": true, "points": 1, "time_spent": 30 }] + answers JSONB NOT NULL DEFAULT '[]'::JSONB, + + -- Calificacion + score DECIMAL(5, 2), -- Puntos obtenidos + max_score DECIMAL(5, 2), -- Puntos maximos posibles + score_percent DECIMAL(5, 2), -- Porcentaje (score/max_score * 100) + passed BOOLEAN, + grade VARCHAR(2), -- 'A', 'B', 'C', 'D', 'F' + + -- Desglose + correct_count INTEGER NOT NULL DEFAULT 0, + incorrect_count INTEGER NOT NULL DEFAULT 0, + partial_count INTEGER NOT NULL DEFAULT 0, -- Respuestas parcialmente correctas + unanswered_count INTEGER NOT NULL DEFAULT 0, + + -- Feedback + feedback_shown BOOLEAN NOT NULL DEFAULT FALSE, + instructor_feedback TEXT, + + -- XP y gamificacion + xp_earned INTEGER NOT NULL DEFAULT 0, + badge_earned UUID, + + -- Metadata + ip_address INET, + user_agent TEXT, + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE education.quiz_attempts IS +'Intentos de quiz con respuestas y calificaciones'; + +COMMENT ON COLUMN education.quiz_attempts.questions_shown IS +'Array de IDs de preguntas en el orden mostrado (puede variar por shuffle)'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_quiz + ON education.quiz_attempts(quiz_id); + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_enrollment + ON education.quiz_attempts(enrollment_id); + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user + ON education.quiz_attempts(user_id); + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_tenant + ON education.quiz_attempts(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_status + ON education.quiz_attempts(status); + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_in_progress + ON education.quiz_attempts(user_id, quiz_id) + WHERE status = 'in_progress'; + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_passed + ON education.quiz_attempts(quiz_id, passed) + WHERE passed = TRUE; + +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_score + ON education.quiz_attempts(quiz_id, score_percent DESC); + +-- GIN index para respuestas +CREATE INDEX IF NOT EXISTS idx_quiz_attempts_answers_gin + ON education.quiz_attempts USING GIN (answers); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS quiz_attempt_updated_at ON education.quiz_attempts; +CREATE TRIGGER quiz_attempt_updated_at + BEFORE UPDATE ON education.quiz_attempts + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para calcular calificacion al enviar +CREATE OR REPLACE FUNCTION education.grade_quiz_attempt() +RETURNS TRIGGER AS $$ +DECLARE + v_quiz RECORD; + v_answer RECORD; + v_total_points DECIMAL := 0; + v_earned_points DECIMAL := 0; + v_correct INTEGER := 0; + v_incorrect INTEGER := 0; + v_partial INTEGER := 0; +BEGIN + -- Solo procesar cuando cambia a submitted + IF NEW.status = 'submitted' AND OLD.status = 'in_progress' THEN + -- Obtener info del quiz + SELECT * INTO v_quiz FROM education.quizzes WHERE id = NEW.quiz_id; + + -- Calcular puntos + FOR v_answer IN SELECT * FROM jsonb_array_elements(NEW.answers) + LOOP + v_total_points := v_total_points + COALESCE((v_answer.value->>'max_points')::DECIMAL, 1); + + IF (v_answer.value->>'is_correct')::BOOLEAN = TRUE THEN + v_earned_points := v_earned_points + COALESCE((v_answer.value->>'points')::DECIMAL, 1); + v_correct := v_correct + 1; + ELSIF (v_answer.value->>'points')::DECIMAL > 0 THEN + v_earned_points := v_earned_points + (v_answer.value->>'points')::DECIMAL; + v_partial := v_partial + 1; + ELSE + v_incorrect := v_incorrect + 1; + END IF; + END LOOP; + + -- Calcular porcentaje + NEW.score := v_earned_points; + NEW.max_score := v_total_points; + IF v_total_points > 0 THEN + NEW.score_percent := (v_earned_points / v_total_points * 100); + END IF; + + -- Determinar si paso + NEW.passed := NEW.score_percent >= v_quiz.passing_score; + + -- Calcular grade + NEW.grade := CASE + WHEN NEW.score_percent >= 90 THEN 'A' + WHEN NEW.score_percent >= 80 THEN 'B' + WHEN NEW.score_percent >= 70 THEN 'C' + WHEN NEW.score_percent >= 60 THEN 'D' + ELSE 'F' + END; + + -- Contadores + NEW.correct_count := v_correct; + NEW.incorrect_count := v_incorrect; + NEW.partial_count := v_partial; + NEW.unanswered_count := NEW.questions_total - jsonb_array_length(NEW.answers); + + -- Tiempo + NEW.submitted_at := NOW(); + NEW.time_spent_seconds := EXTRACT(EPOCH FROM (NOW() - NEW.started_at))::INTEGER; + + -- XP + IF NEW.passed THEN + NEW.xp_earned := v_quiz.xp_reward; + ELSE + NEW.xp_earned := v_quiz.xp_reward / 4; -- XP parcial por intentar + END IF; + + -- Marcar como graded + NEW.status := 'graded'; + NEW.graded_at := NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS quiz_attempt_grade ON education.quiz_attempts; +CREATE TRIGGER quiz_attempt_grade + BEFORE UPDATE OF status ON education.quiz_attempts + FOR EACH ROW + EXECUTE FUNCTION education.grade_quiz_attempt(); + +-- Trigger para actualizar estadisticas del quiz +CREATE OR REPLACE FUNCTION education.update_quiz_stats_on_attempt() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'graded' THEN + UPDATE education.quizzes + SET attempt_count = attempt_count + 1, + average_score = ( + SELECT AVG(score_percent) + FROM education.quiz_attempts + WHERE quiz_id = NEW.quiz_id AND status = 'graded' + ), + pass_rate = ( + SELECT (COUNT(*) FILTER (WHERE passed = TRUE)::DECIMAL / COUNT(*) * 100) + FROM education.quiz_attempts + WHERE quiz_id = NEW.quiz_id AND status = 'graded' + ) + WHERE id = NEW.quiz_id; + + -- Actualizar enrollment + UPDATE education.enrollments + SET quizzes_completed = ( + SELECT COUNT(DISTINCT quiz_id) + FROM education.quiz_attempts + WHERE enrollment_id = NEW.enrollment_id AND status = 'graded' + ), + quizzes_passed = ( + SELECT COUNT(DISTINCT quiz_id) + FROM education.quiz_attempts + WHERE enrollment_id = NEW.enrollment_id AND status = 'graded' AND passed = TRUE + ), + xp_earned = xp_earned + NEW.xp_earned + WHERE id = NEW.enrollment_id; + + -- Actualizar lesson_progress si el quiz esta asociado a una leccion + UPDATE education.lesson_progress + SET quiz_attempted = TRUE, + quiz_passed = NEW.passed, + quiz_score = NEW.score_percent, + is_completed = NEW.passed -- Completar leccion si paso el quiz + WHERE enrollment_id = NEW.enrollment_id + AND lesson_id = (SELECT lesson_id FROM education.quizzes WHERE id = NEW.quiz_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS quiz_attempt_stats ON education.quiz_attempts; +CREATE TRIGGER quiz_attempt_stats + AFTER UPDATE OF status ON education.quiz_attempts + FOR EACH ROW + EXECUTE FUNCTION education.update_quiz_stats_on_attempt(); + +-- Funcion para iniciar un intento +CREATE OR REPLACE FUNCTION education.start_quiz_attempt( + p_quiz_id UUID, + p_enrollment_id UUID +) +RETURNS UUID AS $$ +DECLARE + v_quiz RECORD; + v_enrollment RECORD; + v_attempt_count INTEGER; + v_questions UUID[]; + v_attempt_id UUID; +BEGIN + -- Obtener quiz + SELECT * INTO v_quiz FROM education.quizzes WHERE id = p_quiz_id; + + IF v_quiz IS NULL THEN + RAISE EXCEPTION 'Quiz not found'; + END IF; + + -- Obtener enrollment + SELECT * INTO v_enrollment FROM education.enrollments WHERE id = p_enrollment_id; + + -- Verificar intentos previos + SELECT COUNT(*) INTO v_attempt_count + FROM education.quiz_attempts + WHERE quiz_id = p_quiz_id + AND enrollment_id = p_enrollment_id + AND status NOT IN ('abandoned', 'voided'); + + IF v_quiz.max_attempts IS NOT NULL AND v_attempt_count >= v_quiz.max_attempts THEN + RAISE EXCEPTION 'Maximum attempts reached'; + END IF; + + -- Verificar si hay intento en progreso + IF EXISTS ( + SELECT 1 FROM education.quiz_attempts + WHERE quiz_id = p_quiz_id + AND enrollment_id = p_enrollment_id + AND status = 'in_progress' + ) THEN + RAISE EXCEPTION 'Already have an attempt in progress'; + END IF; + + -- Seleccionar preguntas + SELECT ARRAY_AGG(id ORDER BY + CASE WHEN v_quiz.shuffle_questions THEN RANDOM() ELSE sequence_number END + ) INTO v_questions + FROM education.quiz_questions + WHERE quiz_id = p_quiz_id AND is_active = TRUE + LIMIT COALESCE(v_quiz.questions_to_show, v_quiz.question_count); + + -- Crear intento + INSERT INTO education.quiz_attempts ( + quiz_id, enrollment_id, user_id, tenant_id, + attempt_number, questions_shown, questions_total, + time_limit_at + ) VALUES ( + p_quiz_id, p_enrollment_id, v_enrollment.user_id, v_enrollment.tenant_id, + v_attempt_count + 1, + to_jsonb(v_questions), + array_length(v_questions, 1), + CASE WHEN v_quiz.time_limit_minutes IS NOT NULL + THEN NOW() + (v_quiz.time_limit_minutes || ' minutes')::INTERVAL + ELSE NULL + END + ) + RETURNING id INTO v_attempt_id; + + RETURN v_attempt_id; +END; +$$ LANGUAGE plpgsql; + +-- Vista de intentos de un usuario +CREATE OR REPLACE VIEW education.v_my_quiz_attempts AS +SELECT + qa.id, + qa.quiz_id, + q.title AS quiz_title, + q.type AS quiz_type, + qa.enrollment_id, + qa.attempt_number, + qa.status, + qa.score_percent, + qa.passed, + qa.grade, + qa.correct_count, + qa.questions_total, + qa.time_spent_seconds, + qa.started_at, + qa.submitted_at +FROM education.quiz_attempts qa +JOIN education.quizzes q ON qa.quiz_id = q.id +ORDER BY qa.started_at DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY quiz_attempts_tenant_isolation ON education.quiz_attempts + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY quiz_attempts_user_isolation ON education.quiz_attempts + FOR SELECT + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON education.quiz_attempts TO trading_app; +GRANT SELECT ON education.quiz_attempts TO trading_readonly; +GRANT SELECT ON education.v_my_quiz_attempts TO trading_app; +GRANT EXECUTE ON FUNCTION education.start_quiz_attempt TO trading_app; diff --git a/ddl/schemas/education/tables/010_course_reviews.sql b/ddl/schemas/education/tables/010_course_reviews.sql new file mode 100644 index 0000000..4e99e1b --- /dev/null +++ b/ddl/schemas/education/tables/010_course_reviews.sql @@ -0,0 +1,271 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: course_reviews +-- DESCRIPTION: Resenas y calificaciones de cursos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Resenas de Cursos +CREATE TABLE IF NOT EXISTS education.course_reviews ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Calificacion + rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), + + -- Calificaciones detalladas + rating_content INTEGER CHECK (rating_content BETWEEN 1 AND 5), + rating_instructor INTEGER CHECK (rating_instructor BETWEEN 1 AND 5), + rating_value INTEGER CHECK (rating_value BETWEEN 1 AND 5), + + -- Resena + title VARCHAR(200), + review_text TEXT, + + -- Estado + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected', 'flagged')), + rejection_reason TEXT, + flagged_reason TEXT, + + -- Moderacion + is_verified_purchase BOOLEAN NOT NULL DEFAULT TRUE, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + moderated_by UUID REFERENCES users.users(id), + moderated_at TIMESTAMPTZ, + + -- Utilidad + helpful_count INTEGER NOT NULL DEFAULT 0, + not_helpful_count INTEGER NOT NULL DEFAULT 0, + + -- Respuesta del instructor + instructor_response TEXT, + instructor_response_at TIMESTAMPTZ, + + -- Edicion + is_edited BOOLEAN NOT NULL DEFAULT FALSE, + edited_at TIMESTAMPTZ, + original_review_text TEXT, + + -- Progreso al momento de la resena + progress_at_review DECIMAL(5, 2), + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT course_reviews_unique_user UNIQUE (user_id, course_id) +); + +COMMENT ON TABLE education.course_reviews IS +'Resenas y calificaciones de cursos por estudiantes'; + +COMMENT ON COLUMN education.course_reviews.is_verified_purchase IS +'TRUE si el usuario realmente completo/compro el curso'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_course_reviews_course + ON education.course_reviews(course_id, status) + WHERE status = 'approved'; + +CREATE INDEX IF NOT EXISTS idx_course_reviews_user + ON education.course_reviews(user_id); + +CREATE INDEX IF NOT EXISTS idx_course_reviews_tenant + ON education.course_reviews(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_course_reviews_rating + ON education.course_reviews(course_id, rating DESC) + WHERE status = 'approved'; + +CREATE INDEX IF NOT EXISTS idx_course_reviews_featured + ON education.course_reviews(course_id, is_featured) + WHERE is_featured = TRUE AND status = 'approved'; + +CREATE INDEX IF NOT EXISTS idx_course_reviews_helpful + ON education.course_reviews(course_id, helpful_count DESC) + WHERE status = 'approved'; + +CREATE INDEX IF NOT EXISTS idx_course_reviews_pending + ON education.course_reviews(status, created_at) + WHERE status = 'pending'; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS course_review_updated_at ON education.course_reviews; +CREATE TRIGGER course_review_updated_at + BEFORE UPDATE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Trigger para actualizar rating promedio del curso +CREATE OR REPLACE FUNCTION education.update_course_rating() +RETURNS TRIGGER AS $$ +DECLARE + v_avg_rating DECIMAL(3, 2); + v_review_count INTEGER; +BEGIN + -- Calcular nuevo promedio + SELECT + COALESCE(AVG(rating), 0), + COUNT(*) + INTO v_avg_rating, v_review_count + FROM education.course_reviews + WHERE course_id = COALESCE(NEW.course_id, OLD.course_id) + AND status = 'approved'; + + -- Actualizar curso + UPDATE education.courses + SET average_rating = v_avg_rating, + review_count = v_review_count + WHERE id = COALESCE(NEW.course_id, OLD.course_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS course_review_rating ON education.course_reviews; +CREATE TRIGGER course_review_rating + AFTER INSERT OR UPDATE OF rating, status OR DELETE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_course_rating(); + +-- Trigger para guardar texto original al editar +CREATE OR REPLACE FUNCTION education.save_original_review() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.review_text IS DISTINCT FROM OLD.review_text AND OLD.original_review_text IS NULL THEN + NEW.original_review_text := OLD.review_text; + NEW.is_edited := TRUE; + NEW.edited_at := NOW(); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS course_review_edit ON education.course_reviews; +CREATE TRIGGER course_review_edit + BEFORE UPDATE OF review_text ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.save_original_review(); + +-- Tabla auxiliar para votos de utilidad +CREATE TABLE IF NOT EXISTS education.review_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + review_id UUID NOT NULL REFERENCES education.course_reviews(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + is_helpful BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT review_votes_unique UNIQUE (review_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_review_votes_review ON education.review_votes(review_id); +CREATE INDEX IF NOT EXISTS idx_review_votes_user ON education.review_votes(user_id); + +-- Trigger para actualizar contadores de votos +CREATE OR REPLACE FUNCTION education.update_review_vote_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + UPDATE education.course_reviews + SET helpful_count = ( + SELECT COUNT(*) FROM education.review_votes + WHERE review_id = NEW.review_id AND is_helpful = TRUE + ), + not_helpful_count = ( + SELECT COUNT(*) FROM education.review_votes + WHERE review_id = NEW.review_id AND is_helpful = FALSE + ) + WHERE id = NEW.review_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE education.course_reviews + SET helpful_count = ( + SELECT COUNT(*) FROM education.review_votes + WHERE review_id = OLD.review_id AND is_helpful = TRUE + ), + not_helpful_count = ( + SELECT COUNT(*) FROM education.review_votes + WHERE review_id = OLD.review_id AND is_helpful = FALSE + ) + WHERE id = OLD.review_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS review_vote_counts ON education.review_votes; +CREATE TRIGGER review_vote_counts + AFTER INSERT OR UPDATE OR DELETE ON education.review_votes + FOR EACH ROW + EXECUTE FUNCTION education.update_review_vote_counts(); + +-- Vista de resenas de un curso +CREATE OR REPLACE VIEW education.v_course_reviews AS +SELECT + r.id, + r.course_id, + r.user_id, + u.first_name, + u.last_name, + u.avatar_url, + r.rating, + r.rating_content, + r.rating_instructor, + r.rating_value, + r.title, + r.review_text, + r.is_verified_purchase, + r.is_featured, + r.helpful_count, + r.instructor_response, + r.instructor_response_at, + r.is_edited, + r.progress_at_review, + r.created_at +FROM education.course_reviews r +JOIN users.users u ON r.user_id = u.id +WHERE r.status = 'approved' +ORDER BY r.is_featured DESC, r.helpful_count DESC, r.created_at DESC; + +-- Vista de distribucion de ratings +CREATE OR REPLACE VIEW education.v_course_rating_distribution AS +SELECT + course_id, + rating, + COUNT(*) AS count, + (COUNT(*)::DECIMAL / SUM(COUNT(*)) OVER (PARTITION BY course_id) * 100) AS percentage +FROM education.course_reviews +WHERE status = 'approved' +GROUP BY course_id, rating +ORDER BY course_id, rating DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.course_reviews ENABLE ROW LEVEL SECURITY; + +CREATE POLICY course_reviews_tenant_isolation ON education.course_reviews + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +ALTER TABLE education.review_votes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY review_votes_user_isolation ON education.review_votes + FOR ALL + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON education.course_reviews TO trading_app; +GRANT SELECT ON education.course_reviews TO trading_readonly; +GRANT SELECT, INSERT, UPDATE, DELETE ON education.review_votes TO trading_app; +GRANT SELECT ON education.v_course_reviews TO trading_app; +GRANT SELECT ON education.v_course_rating_distribution TO trading_app; diff --git a/ddl/schemas/education/tables/011_certificates.sql b/ddl/schemas/education/tables/011_certificates.sql new file mode 100644 index 0000000..6b7b277 --- /dev/null +++ b/ddl/schemas/education/tables/011_certificates.sql @@ -0,0 +1,379 @@ +-- ============================================================================ +-- SCHEMA: education +-- TABLE: certificates +-- DESCRIPTION: Certificados de finalizacion de cursos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de certificado +DO $$ BEGIN + CREATE TYPE education.certificate_type AS ENUM ( + 'completion', -- Certificado de finalizacion + 'achievement', -- Certificado de logro especifico + 'participation', -- Certificado de participacion + 'excellence', -- Certificado de excelencia (alta puntuacion) + 'professional' -- Certificacion profesional + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Certificados +CREATE TABLE IF NOT EXISTS education.certificates ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + certificate_number VARCHAR(50) NOT NULL UNIQUE, -- Numero unico verificable + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Tipo + type education.certificate_type NOT NULL DEFAULT 'completion', + + -- Datos del certificado + title VARCHAR(200) NOT NULL, + description TEXT, + recipient_name VARCHAR(200) NOT NULL, -- Nombre como aparece en certificado + + -- Logro + final_score DECIMAL(5, 2), + grade VARCHAR(2), + completion_date DATE NOT NULL DEFAULT CURRENT_DATE, + hours_completed INTEGER, + + -- Template y diseƱo + template_id UUID, + template_data JSONB DEFAULT '{}'::JSONB, -- Datos para el template + background_url TEXT, + + -- Archivos generados + pdf_url TEXT, + image_url TEXT, + linkedin_url TEXT, -- URL para compartir en LinkedIn + + -- Verificacion + verification_code VARCHAR(20) NOT NULL, -- Codigo corto para verificar + verification_url TEXT, + qr_code_url TEXT, + + -- Blockchain (opcional) + blockchain_hash VARCHAR(66), -- Hash de verificacion en blockchain + blockchain_network VARCHAR(50), + blockchain_tx_id VARCHAR(66), + + -- Firmantes + signed_by JSONB DEFAULT '[]'::JSONB, -- [{ "name": "...", "title": "...", "signature_url": "..." }] + + -- Expiracion (para certificaciones profesionales) + expires_at TIMESTAMPTZ, + is_expired BOOLEAN NOT NULL DEFAULT FALSE, + renewal_available BOOLEAN NOT NULL DEFAULT FALSE, + + -- Estado + is_valid BOOLEAN NOT NULL DEFAULT TRUE, + revoked_at TIMESTAMPTZ, + revocation_reason TEXT, + + -- Compartido + is_public BOOLEAN NOT NULL DEFAULT FALSE, + public_url TEXT, + share_count INTEGER NOT NULL DEFAULT 0, + view_count INTEGER NOT NULL DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT certificates_unique_enrollment UNIQUE (enrollment_id, type) +); + +COMMENT ON TABLE education.certificates IS +'Certificados de finalizacion emitidos a estudiantes'; + +COMMENT ON COLUMN education.certificates.certificate_number IS +'Numero unico para verificacion publica del certificado'; + +COMMENT ON COLUMN education.certificates.verification_code IS +'Codigo corto (6-8 caracteres) para verificacion rapida'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_certificates_enrollment + ON education.certificates(enrollment_id); + +CREATE INDEX IF NOT EXISTS idx_certificates_course + ON education.certificates(course_id); + +CREATE INDEX IF NOT EXISTS idx_certificates_user + ON education.certificates(user_id); + +CREATE INDEX IF NOT EXISTS idx_certificates_tenant + ON education.certificates(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_certificates_number + ON education.certificates(certificate_number); + +CREATE INDEX IF NOT EXISTS idx_certificates_verification + ON education.certificates(verification_code); + +CREATE INDEX IF NOT EXISTS idx_certificates_type + ON education.certificates(type); + +CREATE INDEX IF NOT EXISTS idx_certificates_valid + ON education.certificates(is_valid) + WHERE is_valid = TRUE; + +CREATE INDEX IF NOT EXISTS idx_certificates_public + ON education.certificates(is_public, issued_at DESC) + WHERE is_public = TRUE; + +CREATE INDEX IF NOT EXISTS idx_certificates_expires + ON education.certificates(expires_at) + WHERE expires_at IS NOT NULL AND is_valid = TRUE; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS certificate_updated_at ON education.certificates; +CREATE TRIGGER certificate_updated_at + BEFORE UPDATE ON education.certificates + FOR EACH ROW + EXECUTE FUNCTION education.update_education_timestamp(); + +-- Funcion para generar numero de certificado +CREATE OR REPLACE FUNCTION education.generate_certificate_number() +RETURNS TEXT AS $$ +DECLARE + v_year TEXT := TO_CHAR(NOW(), 'YYYY'); + v_seq TEXT; +BEGIN + -- Formato: CERT-YYYY-XXXXXX (ej: CERT-2026-000123) + SELECT LPAD(COALESCE(MAX( + NULLIF(SUBSTRING(certificate_number FROM 11), '')::INTEGER + ), 0) + 1, 6, '0') + INTO v_seq + FROM education.certificates + WHERE certificate_number LIKE 'CERT-' || v_year || '-%'; + + RETURN 'CERT-' || v_year || '-' || v_seq; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para generar codigo de verificacion +CREATE OR REPLACE FUNCTION education.generate_verification_code() +RETURNS TEXT AS $$ +DECLARE + v_chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Sin I, O, 0, 1 para evitar confusion + v_code TEXT := ''; + i INTEGER; +BEGIN + FOR i IN 1..8 LOOP + v_code := v_code || SUBSTRING(v_chars FROM FLOOR(RANDOM() * LENGTH(v_chars) + 1)::INTEGER FOR 1); + END LOOP; + RETURN v_code; +END; +$$ LANGUAGE plpgsql; + +-- Trigger para generar numeros automaticamente +CREATE OR REPLACE FUNCTION education.set_certificate_numbers() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.certificate_number IS NULL THEN + NEW.certificate_number := education.generate_certificate_number(); + END IF; + + IF NEW.verification_code IS NULL THEN + NEW.verification_code := education.generate_verification_code(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS certificate_numbers ON education.certificates; +CREATE TRIGGER certificate_numbers + BEFORE INSERT ON education.certificates + FOR EACH ROW + EXECUTE FUNCTION education.set_certificate_numbers(); + +-- Funcion para emitir certificado +CREATE OR REPLACE FUNCTION education.issue_certificate( + p_enrollment_id UUID, + p_type education.certificate_type DEFAULT 'completion' +) +RETURNS UUID AS $$ +DECLARE + v_enrollment RECORD; + v_course RECORD; + v_user RECORD; + v_cert_id UUID; +BEGIN + -- Obtener enrollment + SELECT * INTO v_enrollment + FROM education.enrollments + WHERE id = p_enrollment_id; + + IF v_enrollment IS NULL THEN + RAISE EXCEPTION 'Enrollment not found'; + END IF; + + -- Verificar que el curso este completado + IF v_enrollment.status != 'completed' AND p_type = 'completion' THEN + RAISE EXCEPTION 'Course not completed'; + END IF; + + -- Obtener curso + SELECT * INTO v_course + FROM education.courses + WHERE id = v_enrollment.course_id; + + -- Obtener usuario + SELECT * INTO v_user + FROM users.users + WHERE id = v_enrollment.user_id; + + -- Verificar si ya existe + IF EXISTS ( + SELECT 1 FROM education.certificates + WHERE enrollment_id = p_enrollment_id AND type = p_type + ) THEN + RAISE EXCEPTION 'Certificate already issued'; + END IF; + + -- Crear certificado + INSERT INTO education.certificates ( + enrollment_id, course_id, user_id, tenant_id, + type, title, recipient_name, + final_score, grade, hours_completed + ) VALUES ( + p_enrollment_id, v_enrollment.course_id, v_enrollment.user_id, v_enrollment.tenant_id, + p_type, + v_course.title || ' - Certificate of ' || + CASE p_type + WHEN 'completion' THEN 'Completion' + WHEN 'excellence' THEN 'Excellence' + WHEN 'participation' THEN 'Participation' + ELSE 'Achievement' + END, + COALESCE(v_user.first_name || ' ' || v_user.last_name, v_user.email), + v_enrollment.final_score, + v_enrollment.final_grade, + v_course.estimated_duration_minutes / 60 + ) + RETURNING id INTO v_cert_id; + + -- Actualizar enrollment + UPDATE education.enrollments + SET certificate_issued = TRUE, + certificate_id = v_cert_id, + certificate_issued_at = NOW() + WHERE id = p_enrollment_id; + + RETURN v_cert_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para verificar certificado +CREATE OR REPLACE FUNCTION education.verify_certificate( + p_code VARCHAR(50) +) +RETURNS TABLE ( + is_valid BOOLEAN, + certificate_number VARCHAR(50), + recipient_name VARCHAR(200), + course_title VARCHAR(200), + completion_date DATE, + grade VARCHAR(2), + issued_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + c.is_valid AND (c.expires_at IS NULL OR c.expires_at > NOW()), + c.certificate_number, + c.recipient_name, + co.title, + c.completion_date, + c.grade, + c.issued_at, + c.expires_at + FROM education.certificates c + JOIN education.courses co ON c.course_id = co.id + WHERE c.certificate_number = p_code + OR c.verification_code = p_code; +END; +$$ LANGUAGE plpgsql; + +-- Vista de mis certificados +CREATE OR REPLACE VIEW education.v_my_certificates AS +SELECT + c.id, + c.certificate_number, + c.course_id, + co.title AS course_title, + co.thumbnail_url AS course_thumbnail, + c.type, + c.title AS certificate_title, + c.recipient_name, + c.final_score, + c.grade, + c.completion_date, + c.pdf_url, + c.image_url, + c.verification_code, + c.is_public, + c.public_url, + c.expires_at, + c.is_valid, + c.issued_at +FROM education.certificates c +JOIN education.courses co ON c.course_id = co.id +WHERE c.is_valid = TRUE +ORDER BY c.issued_at DESC; + +-- Vista de certificados publicos de un curso +CREATE OR REPLACE VIEW education.v_course_certificates AS +SELECT + c.id, + c.course_id, + c.recipient_name, + c.type, + c.grade, + c.completion_date, + c.issued_at +FROM education.certificates c +WHERE c.is_public = TRUE + AND c.is_valid = TRUE +ORDER BY c.issued_at DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY; + +CREATE POLICY certificates_tenant_isolation ON education.certificates + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY certificates_user_isolation ON education.certificates + FOR SELECT + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Policy especial para verificacion publica +CREATE POLICY certificates_public_verify ON education.certificates + FOR SELECT + USING (is_public = TRUE OR is_valid = TRUE); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON education.certificates TO trading_app; +GRANT SELECT ON education.certificates TO trading_readonly; +GRANT SELECT ON education.v_my_certificates TO trading_app; +GRANT SELECT ON education.v_course_certificates TO trading_app; +GRANT EXECUTE ON FUNCTION education.issue_certificate TO trading_app; +GRANT EXECUTE ON FUNCTION education.verify_certificate TO trading_app; +GRANT EXECUTE ON FUNCTION education.generate_certificate_number TO trading_app; +GRANT EXECUTE ON FUNCTION education.generate_verification_code TO trading_app;