feat: quiz and rbac

This commit is contained in:
Krispire Admin
2026-10-15 19:43:17 +05:30
parent 7d7ba2a002
commit 4b38047c26
26 changed files with 3270 additions and 75 deletions
+7
View File
@@ -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;
+60
View File
@@ -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;
+5
View File
@@ -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;
+88
View File
@@ -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;
+14
View File
@@ -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;