Files
spsays-bff/migrations/0057_quizzes.up.sql
T
2026-10-15 19:43:17 +05:30

89 lines
4.0 KiB
SQL

-- Quizzes on the library's content.
--
-- A quiz is attached to a piece of content by its app path — a book chapter
-- (/books/bg/2), or a lecture or conversation (/transcriptions/{slug}) — or to
-- nothing at all, for a stand-alone quiz. The path is what the content page
-- already knows about itself, so it can ask "is there a quiz for me?" without
-- the content's Mongo id ever reaching Postgres.
CREATE TABLE IF NOT EXISTS quizzes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
-- Light formatting markers, rendered like the rest of the admin text.
description TEXT NOT NULL DEFAULT '',
-- chapter | lecture | conversation | standalone
target_kind VARCHAR(16) NOT NULL DEFAULT 'standalone',
-- App path of the content, NULL for a stand-alone quiz.
target_path TEXT,
-- Display name of the content, captured when the quiz is made.
target_title TEXT NOT NULL DEFAULT '',
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Soft delete: participants' results stay intact for the record.
deleted_at TIMESTAMPTZ,
CONSTRAINT quizzes_target_kind_check
CHECK (target_kind IN ('chapter', 'lecture', 'conversation', 'standalone')),
CONSTRAINT quizzes_target_consistent
CHECK ((target_kind = 'standalone') = (target_path IS NULL))
);
-- "Is there a quiz for this page?" — asked by every content page load.
CREATE INDEX IF NOT EXISTS quizzes_target_idx
ON quizzes (target_path, published_at DESC)
WHERE deleted_at IS NULL AND published_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS quiz_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL,
prompt TEXT NOT NULL,
-- Optional note shown with the answer on the results screen.
explanation TEXT NOT NULL DEFAULT '',
UNIQUE (quiz_id, ord)
);
CREATE TABLE IF NOT EXISTS quiz_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL,
body TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (question_id, ord),
-- Five options at most; the service also enforces two at least and
-- exactly one correct.
CONSTRAINT quiz_options_ord_check CHECK (ord BETWEEN 0 AND 4)
);
-- One attempt per person per quiz: it is what "Continue" resumes and what
-- "View results" shows.
CREATE TABLE IF NOT EXISTS quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
score INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
UNIQUE (quiz_id, user_id)
);
CREATE INDEX IF NOT EXISTS quiz_attempts_user_idx ON quiz_attempts (user_id);
-- One row per question an attempt has reached.
--
-- Timing is measured by the SERVER: shown_at is stamped when the question is
-- handed to the client, answered_at when the answer arrives. A client-reported
-- duration could be anything; these two timestamps can't be edited from the
-- browser.
CREATE TABLE IF NOT EXISTS quiz_answers (
attempt_id UUID NOT NULL REFERENCES quiz_attempts(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES quiz_questions(id) ON DELETE CASCADE,
option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL,
shown_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ,
response_ms INTEGER,
is_correct BOOLEAN,
PRIMARY KEY (attempt_id, question_id)
);