[DDL] feat: Sprint 2 - Add education schema with 11 tables

## Schema: education (Complete Learning Management System)

### Course Structure (3 tables):
- categories: Hierarchical course categories with materialized paths
- courses: Full course catalog with pricing, access models, gamification
- modules: Course sections/modules with sequencing

### Content (3 tables):
- lessons: Individual lessons (video, article, interactive)
- quizzes: Assessments with configurable rules
- quiz_questions: Question bank with multiple types

### Progress Tracking (3 tables):
- enrollments: User enrollments with progress tracking
- lesson_progress: Detailed per-lesson progress
- quiz_attempts: Quiz attempt history with grading

### Completion (2 tables):
- course_reviews: Student reviews with moderation
- certificates: Verifiable completion certificates

## Features:
- 8 custom ENUMs for education domain
- Multi-tenancy with RLS policies
- Automatic progress calculation triggers
- Quiz grading and statistics
- Certificate generation with verification codes
- Rating aggregation for courses
- Gamification support (XP, badges)

Total: 11 tables, ~95KB of DDL

Roadmap: orchestration/planes/ROADMAP-IMPLEMENTACION-DDL-2026-Q1.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 19:48:39 -06:00
parent b86dfa2e06
commit cd6590ec25
11 changed files with 2888 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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