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