## 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>
266 lines
8.5 KiB
PL/PgSQL
266 lines
8.5 KiB
PL/PgSQL
-- ============================================================================
|
|
-- 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;
|