trading-platform-database-v2/ddl/schemas/education/tables/008_lesson_progress.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

258 lines
8.9 KiB
PL/PgSQL

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