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