trading-platform-database-v2/ddl/schemas/education/tables/007_enrollments.sql
rckrdmrd cd6590ec25 [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>
2026-01-16 19:48:39 -06:00

297 lines
9.4 KiB
PL/PgSQL

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