feat: quiz and rbac
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
-- Restore the old flag for anyone who still has a role, so a rollback keeps
|
||||
-- them in the panel.
|
||||
UPDATE users SET role = 'admin' WHERE admin_role_id IS NOT NULL AND admin_blocked_at IS NULL;
|
||||
DROP INDEX IF EXISTS users_admin_role_idx;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS admin_blocked_at;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS admin_role_id;
|
||||
DROP TABLE IF EXISTS admin_roles;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Role-based access for the admin panel.
|
||||
--
|
||||
-- Until now admin access was one flag: users.role = 'admin'. This replaces it
|
||||
-- with named roles carrying explicit permissions, so a moderator can resolve
|
||||
-- reports without also being able to ban users or gift stickers to everyone.
|
||||
--
|
||||
-- Three system roles are seeded and cannot be edited or deleted; superadmins
|
||||
-- can define further custom roles from any subset of the permission catalog
|
||||
-- (which lives in code — see internal/rbac). The one thing a custom role can
|
||||
-- never hold is admins:manage: granting, changing, revoking and blocking staff
|
||||
-- stays with superadmins.
|
||||
CREATE TABLE IF NOT EXISTS admin_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key VARCHAR(40) NOT NULL UNIQUE,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
-- System roles are seeded below and are read-only.
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- "resource:action" strings, or '*' for everything (superadmin only).
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO admin_roles (key, name, description, is_system, permissions) VALUES
|
||||
('superadmin', 'Superadmin',
|
||||
'Everything, including managing other staff.', TRUE, ARRAY['*']),
|
||||
('admin', 'Admin',
|
||||
'Runs the platform day to day. Cannot manage staff.', TRUE, ARRAY[
|
||||
'stats:read','users:read','users:notify','users:ban','users:limits',
|
||||
'disciples:manage','posts:moderate','comments:moderate','collections:read',
|
||||
'feedback:read','feedback:reply','feedback:resolve',
|
||||
'reports:read','reports:resolve','vani:read','vani:write',
|
||||
'rewards:read','rewards:write','rewards:send','rewards:delete',
|
||||
'broadcasts:read','broadcasts:write','broadcasts:publish',
|
||||
'short_urls:read','quizzes:read','quizzes:write','quizzes:delete','quizzes:results']),
|
||||
('moderator', 'Moderator',
|
||||
'Keeps the community healthy: reports, moderation and feedback.', TRUE, ARRAY[
|
||||
'stats:read','users:read','posts:moderate','comments:moderate',
|
||||
'collections:read','feedback:read','feedback:reply','feedback:resolve',
|
||||
'reports:read','reports:resolve'])
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Staff membership. NULL = not staff.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS admin_role_id UUID REFERENCES admin_roles(id) ON DELETE RESTRICT;
|
||||
|
||||
-- A superadmin can suspend another staff member's panel access without taking
|
||||
-- their role away, so restoring them is one click rather than a re-grant.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_blocked_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_admin_role_idx
|
||||
ON users (admin_role_id) WHERE admin_role_id IS NOT NULL;
|
||||
|
||||
-- Everyone who was an admin under the old single flag becomes a superadmin:
|
||||
-- they had unrestricted access before, so anything less would lock existing
|
||||
-- staff out of the panel the moment this deploys.
|
||||
UPDATE users
|
||||
SET admin_role_id = (SELECT id FROM admin_roles WHERE key = 'superadmin')
|
||||
WHERE role = 'admin' AND admin_role_id IS NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS quiz_answers;
|
||||
DROP TABLE IF EXISTS quiz_attempts;
|
||||
DROP TABLE IF EXISTS quiz_options;
|
||||
DROP TABLE IF EXISTS quiz_questions;
|
||||
DROP TABLE IF EXISTS quizzes;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_id UUID REFERENCES quiz_options(id) ON DELETE SET NULL;
|
||||
-- Lossy for multi answers: keeps the first chosen option only.
|
||||
UPDATE quiz_answers SET option_id = option_ids[1] WHERE option_ids IS NOT NULL;
|
||||
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_ids;
|
||||
ALTER TABLE quiz_questions DROP COLUMN IF EXISTS multi;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Multiple-choice questions with more than one correct answer.
|
||||
--
|
||||
-- A question marked multi asks the reader to "select all that apply"; it is
|
||||
-- scored correct only when the chosen set equals the correct set exactly. A
|
||||
-- single-answer question keeps exactly one correct option.
|
||||
ALTER TABLE quiz_questions ADD COLUMN IF NOT EXISTS multi BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- An answer is now a set of options. Arrays can't carry a foreign key; that
|
||||
-- is safe here because a quiz's options are frozen once anyone has answered,
|
||||
-- and options only ever go away with their whole question.
|
||||
ALTER TABLE quiz_answers ADD COLUMN IF NOT EXISTS option_ids UUID[];
|
||||
UPDATE quiz_answers SET option_ids = ARRAY[option_id]
|
||||
WHERE option_id IS NOT NULL AND option_ids IS NULL;
|
||||
ALTER TABLE quiz_answers DROP COLUMN IF EXISTS option_id;
|
||||
Reference in New Issue
Block a user