89 lines
4.0 KiB
SQL
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)
|
|
);
|