-- ============================================================================ -- SCHEMA: education -- TABLE: quiz_attempts -- DESCRIPTION: Intentos de quizzes por usuarios -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 2 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para estado del intento DO $$ BEGIN CREATE TYPE education.attempt_status AS ENUM ( 'in_progress', -- En progreso 'submitted', -- Enviado, pendiente calificacion 'graded', -- Calificado 'abandoned', -- Abandonado (tiempo expirado) 'voided' -- Anulado por admin ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de Intentos de Quiz CREATE TABLE IF NOT EXISTS education.quiz_attempts ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, enrollment_id UUID NOT NULL REFERENCES education.enrollments(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, -- Numero de intento attempt_number INTEGER NOT NULL DEFAULT 1, -- Estado status education.attempt_status NOT NULL DEFAULT 'in_progress', -- Tiempo started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), submitted_at TIMESTAMPTZ, graded_at TIMESTAMPTZ, time_limit_at TIMESTAMPTZ, -- Cuando expira el tiempo time_spent_seconds INTEGER, -- Preguntas questions_shown JSONB NOT NULL DEFAULT '[]'::JSONB, -- IDs de preguntas mostradas (si shuffle) questions_total INTEGER NOT NULL DEFAULT 0, questions_answered INTEGER NOT NULL DEFAULT 0, -- Respuestas -- [{ "question_id": "...", "answer": "...", "is_correct": true, "points": 1, "time_spent": 30 }] answers JSONB NOT NULL DEFAULT '[]'::JSONB, -- Calificacion score DECIMAL(5, 2), -- Puntos obtenidos max_score DECIMAL(5, 2), -- Puntos maximos posibles score_percent DECIMAL(5, 2), -- Porcentaje (score/max_score * 100) passed BOOLEAN, grade VARCHAR(2), -- 'A', 'B', 'C', 'D', 'F' -- Desglose correct_count INTEGER NOT NULL DEFAULT 0, incorrect_count INTEGER NOT NULL DEFAULT 0, partial_count INTEGER NOT NULL DEFAULT 0, -- Respuestas parcialmente correctas unanswered_count INTEGER NOT NULL DEFAULT 0, -- Feedback feedback_shown BOOLEAN NOT NULL DEFAULT FALSE, instructor_feedback TEXT, -- XP y gamificacion xp_earned INTEGER NOT NULL DEFAULT 0, badge_earned UUID, -- Metadata ip_address INET, user_agent TEXT, metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE education.quiz_attempts IS 'Intentos de quiz con respuestas y calificaciones'; COMMENT ON COLUMN education.quiz_attempts.questions_shown IS 'Array de IDs de preguntas en el orden mostrado (puede variar por shuffle)'; -- Indices CREATE INDEX IF NOT EXISTS idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id); CREATE INDEX IF NOT EXISTS idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id); CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user ON education.quiz_attempts(user_id); CREATE INDEX IF NOT EXISTS idx_quiz_attempts_tenant ON education.quiz_attempts(tenant_id); CREATE INDEX IF NOT EXISTS idx_quiz_attempts_status ON education.quiz_attempts(status); CREATE INDEX IF NOT EXISTS idx_quiz_attempts_in_progress ON education.quiz_attempts(user_id, quiz_id) WHERE status = 'in_progress'; CREATE INDEX IF NOT EXISTS idx_quiz_attempts_passed ON education.quiz_attempts(quiz_id, passed) WHERE passed = TRUE; CREATE INDEX IF NOT EXISTS idx_quiz_attempts_score ON education.quiz_attempts(quiz_id, score_percent DESC); -- GIN index para respuestas CREATE INDEX IF NOT EXISTS idx_quiz_attempts_answers_gin ON education.quiz_attempts USING GIN (answers); -- Trigger para updated_at DROP TRIGGER IF EXISTS quiz_attempt_updated_at ON education.quiz_attempts; CREATE TRIGGER quiz_attempt_updated_at BEFORE UPDATE ON education.quiz_attempts FOR EACH ROW EXECUTE FUNCTION education.update_education_timestamp(); -- Trigger para calcular calificacion al enviar CREATE OR REPLACE FUNCTION education.grade_quiz_attempt() RETURNS TRIGGER AS $$ DECLARE v_quiz RECORD; v_answer RECORD; v_total_points DECIMAL := 0; v_earned_points DECIMAL := 0; v_correct INTEGER := 0; v_incorrect INTEGER := 0; v_partial INTEGER := 0; BEGIN -- Solo procesar cuando cambia a submitted IF NEW.status = 'submitted' AND OLD.status = 'in_progress' THEN -- Obtener info del quiz SELECT * INTO v_quiz FROM education.quizzes WHERE id = NEW.quiz_id; -- Calcular puntos FOR v_answer IN SELECT * FROM jsonb_array_elements(NEW.answers) LOOP v_total_points := v_total_points + COALESCE((v_answer.value->>'max_points')::DECIMAL, 1); IF (v_answer.value->>'is_correct')::BOOLEAN = TRUE THEN v_earned_points := v_earned_points + COALESCE((v_answer.value->>'points')::DECIMAL, 1); v_correct := v_correct + 1; ELSIF (v_answer.value->>'points')::DECIMAL > 0 THEN v_earned_points := v_earned_points + (v_answer.value->>'points')::DECIMAL; v_partial := v_partial + 1; ELSE v_incorrect := v_incorrect + 1; END IF; END LOOP; -- Calcular porcentaje NEW.score := v_earned_points; NEW.max_score := v_total_points; IF v_total_points > 0 THEN NEW.score_percent := (v_earned_points / v_total_points * 100); END IF; -- Determinar si paso NEW.passed := NEW.score_percent >= v_quiz.passing_score; -- Calcular grade NEW.grade := CASE WHEN NEW.score_percent >= 90 THEN 'A' WHEN NEW.score_percent >= 80 THEN 'B' WHEN NEW.score_percent >= 70 THEN 'C' WHEN NEW.score_percent >= 60 THEN 'D' ELSE 'F' END; -- Contadores NEW.correct_count := v_correct; NEW.incorrect_count := v_incorrect; NEW.partial_count := v_partial; NEW.unanswered_count := NEW.questions_total - jsonb_array_length(NEW.answers); -- Tiempo NEW.submitted_at := NOW(); NEW.time_spent_seconds := EXTRACT(EPOCH FROM (NOW() - NEW.started_at))::INTEGER; -- XP IF NEW.passed THEN NEW.xp_earned := v_quiz.xp_reward; ELSE NEW.xp_earned := v_quiz.xp_reward / 4; -- XP parcial por intentar END IF; -- Marcar como graded NEW.status := 'graded'; NEW.graded_at := NOW(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS quiz_attempt_grade ON education.quiz_attempts; CREATE TRIGGER quiz_attempt_grade BEFORE UPDATE OF status ON education.quiz_attempts FOR EACH ROW EXECUTE FUNCTION education.grade_quiz_attempt(); -- Trigger para actualizar estadisticas del quiz CREATE OR REPLACE FUNCTION education.update_quiz_stats_on_attempt() RETURNS TRIGGER AS $$ BEGIN IF NEW.status = 'graded' THEN UPDATE education.quizzes SET attempt_count = attempt_count + 1, average_score = ( SELECT AVG(score_percent) FROM education.quiz_attempts WHERE quiz_id = NEW.quiz_id AND status = 'graded' ), pass_rate = ( SELECT (COUNT(*) FILTER (WHERE passed = TRUE)::DECIMAL / COUNT(*) * 100) FROM education.quiz_attempts WHERE quiz_id = NEW.quiz_id AND status = 'graded' ) WHERE id = NEW.quiz_id; -- Actualizar enrollment UPDATE education.enrollments SET quizzes_completed = ( SELECT COUNT(DISTINCT quiz_id) FROM education.quiz_attempts WHERE enrollment_id = NEW.enrollment_id AND status = 'graded' ), quizzes_passed = ( SELECT COUNT(DISTINCT quiz_id) FROM education.quiz_attempts WHERE enrollment_id = NEW.enrollment_id AND status = 'graded' AND passed = TRUE ), xp_earned = xp_earned + NEW.xp_earned WHERE id = NEW.enrollment_id; -- Actualizar lesson_progress si el quiz esta asociado a una leccion UPDATE education.lesson_progress SET quiz_attempted = TRUE, quiz_passed = NEW.passed, quiz_score = NEW.score_percent, is_completed = NEW.passed -- Completar leccion si paso el quiz WHERE enrollment_id = NEW.enrollment_id AND lesson_id = (SELECT lesson_id FROM education.quizzes WHERE id = NEW.quiz_id); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS quiz_attempt_stats ON education.quiz_attempts; CREATE TRIGGER quiz_attempt_stats AFTER UPDATE OF status ON education.quiz_attempts FOR EACH ROW EXECUTE FUNCTION education.update_quiz_stats_on_attempt(); -- Funcion para iniciar un intento CREATE OR REPLACE FUNCTION education.start_quiz_attempt( p_quiz_id UUID, p_enrollment_id UUID ) RETURNS UUID AS $$ DECLARE v_quiz RECORD; v_enrollment RECORD; v_attempt_count INTEGER; v_questions UUID[]; v_attempt_id UUID; BEGIN -- Obtener quiz SELECT * INTO v_quiz FROM education.quizzes WHERE id = p_quiz_id; IF v_quiz IS NULL THEN RAISE EXCEPTION 'Quiz not found'; END IF; -- Obtener enrollment SELECT * INTO v_enrollment FROM education.enrollments WHERE id = p_enrollment_id; -- Verificar intentos previos SELECT COUNT(*) INTO v_attempt_count FROM education.quiz_attempts WHERE quiz_id = p_quiz_id AND enrollment_id = p_enrollment_id AND status NOT IN ('abandoned', 'voided'); IF v_quiz.max_attempts IS NOT NULL AND v_attempt_count >= v_quiz.max_attempts THEN RAISE EXCEPTION 'Maximum attempts reached'; END IF; -- Verificar si hay intento en progreso IF EXISTS ( SELECT 1 FROM education.quiz_attempts WHERE quiz_id = p_quiz_id AND enrollment_id = p_enrollment_id AND status = 'in_progress' ) THEN RAISE EXCEPTION 'Already have an attempt in progress'; END IF; -- Seleccionar preguntas SELECT ARRAY_AGG(id ORDER BY CASE WHEN v_quiz.shuffle_questions THEN RANDOM() ELSE sequence_number END ) INTO v_questions FROM education.quiz_questions WHERE quiz_id = p_quiz_id AND is_active = TRUE LIMIT COALESCE(v_quiz.questions_to_show, v_quiz.question_count); -- Crear intento INSERT INTO education.quiz_attempts ( quiz_id, enrollment_id, user_id, tenant_id, attempt_number, questions_shown, questions_total, time_limit_at ) VALUES ( p_quiz_id, p_enrollment_id, v_enrollment.user_id, v_enrollment.tenant_id, v_attempt_count + 1, to_jsonb(v_questions), array_length(v_questions, 1), CASE WHEN v_quiz.time_limit_minutes IS NOT NULL THEN NOW() + (v_quiz.time_limit_minutes || ' minutes')::INTERVAL ELSE NULL END ) RETURNING id INTO v_attempt_id; RETURN v_attempt_id; END; $$ LANGUAGE plpgsql; -- Vista de intentos de un usuario CREATE OR REPLACE VIEW education.v_my_quiz_attempts AS SELECT qa.id, qa.quiz_id, q.title AS quiz_title, q.type AS quiz_type, qa.enrollment_id, qa.attempt_number, qa.status, qa.score_percent, qa.passed, qa.grade, qa.correct_count, qa.questions_total, qa.time_spent_seconds, qa.started_at, qa.submitted_at FROM education.quiz_attempts qa JOIN education.quizzes q ON qa.quiz_id = q.id ORDER BY qa.started_at DESC; -- RLS Policy para multi-tenancy ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY; CREATE POLICY quiz_attempts_tenant_isolation ON education.quiz_attempts FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); CREATE POLICY quiz_attempts_user_isolation ON education.quiz_attempts FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON education.quiz_attempts TO trading_app; GRANT SELECT ON education.quiz_attempts TO trading_readonly; GRANT SELECT ON education.v_my_quiz_attempts TO trading_app; GRANT EXECUTE ON FUNCTION education.start_quiz_attempt TO trading_app;