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

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;