[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:
parent
b86dfa2e06
commit
cd6590ec25
236
ddl/schemas/education/tables/001_categories.sql
Normal file
236
ddl/schemas/education/tables/001_categories.sql
Normal 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;
|
||||
269
ddl/schemas/education/tables/002_courses.sql
Normal file
269
ddl/schemas/education/tables/002_courses.sql
Normal 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;
|
||||
134
ddl/schemas/education/tables/003_modules.sql
Normal file
134
ddl/schemas/education/tables/003_modules.sql
Normal 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;
|
||||
185
ddl/schemas/education/tables/004_lessons.sql
Normal file
185
ddl/schemas/education/tables/004_lessons.sql
Normal 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;
|
||||
219
ddl/schemas/education/tables/005_quizzes.sql
Normal file
219
ddl/schemas/education/tables/005_quizzes.sql
Normal 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;
|
||||
265
ddl/schemas/education/tables/006_quiz_questions.sql
Normal file
265
ddl/schemas/education/tables/006_quiz_questions.sql
Normal 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;
|
||||
296
ddl/schemas/education/tables/007_enrollments.sql
Normal file
296
ddl/schemas/education/tables/007_enrollments.sql
Normal 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;
|
||||
257
ddl/schemas/education/tables/008_lesson_progress.sql
Normal file
257
ddl/schemas/education/tables/008_lesson_progress.sql
Normal 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;
|
||||
377
ddl/schemas/education/tables/009_quiz_attempts.sql
Normal file
377
ddl/schemas/education/tables/009_quiz_attempts.sql
Normal 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;
|
||||
271
ddl/schemas/education/tables/010_course_reviews.sql
Normal file
271
ddl/schemas/education/tables/010_course_reviews.sql
Normal 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;
|
||||
379
ddl/schemas/education/tables/011_certificates.sql
Normal file
379
ddl/schemas/education/tables/011_certificates.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user