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