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