mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility. Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
This commit is contained in:
1 parent
b43804d8fd
commit
94c76535a4
30 files changed
+46906
-128
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Production catalogue snapshots
|
||||
|
||||
These files are immutable, version-controlled backups of Sauci's
|
||||
developer-authored catalogue. They deliberately exclude subscriber accounts,
|
||||
couples, responses, matches, messages, media, subscriptions, and other customer
|
||||
data.
|
||||
|
||||
`production-2026-08-28.json` contains every production row from:
|
||||
|
||||
- `categories`
|
||||
- `topics`
|
||||
- `question_packs`
|
||||
- `questions`, including soft-deleted rows and inverse links
|
||||
- `dare_packs`
|
||||
- `dares`
|
||||
- `pack_topics`
|
||||
|
||||
The snapshot is archival input, not an automatically executed seed. Restoration
|
||||
must be implemented as an explicit, reviewed operation that validates the target
|
||||
environment, schema compatibility, foreign keys, row counts, and checksum before
|
||||
writing anything.
|
||||
|
||||
Verify the checked-in snapshot from this directory with:
|
||||
|
||||
```sh
|
||||
shasum -a 256 -c production-2026-08-28.sha256
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
042551fd33c8994209f44b1a269716c82e8a0d69b87434373c16407d6e7d48ff production-2026-08-28.json
|
||||
@@ -0,0 +1,411 @@
|
||||
-- Add reversible catalogue review metadata without changing customer visibility.
|
||||
--
|
||||
-- IMPORTANT: Current mobile/API queries intentionally do not filter content_status.
|
||||
-- This migration only prepares and audits the catalogue for a later, separately
|
||||
-- verified enforcement release.
|
||||
|
||||
CREATE TYPE public.content_review_status AS ENUM (
|
||||
'unreviewed',
|
||||
'allowed',
|
||||
'archived'
|
||||
);
|
||||
|
||||
CREATE TYPE public.content_entity_type AS ENUM (
|
||||
'categories',
|
||||
'question_packs',
|
||||
'questions',
|
||||
'dare_packs',
|
||||
'dares'
|
||||
);
|
||||
|
||||
ALTER TABLE public.categories
|
||||
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN content_review_reason TEXT,
|
||||
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.question_packs
|
||||
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN content_review_reason TEXT,
|
||||
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.questions
|
||||
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN content_review_reason TEXT,
|
||||
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.dare_packs
|
||||
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN content_review_reason TEXT,
|
||||
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.dares
|
||||
ADD COLUMN content_status public.content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN content_review_reason TEXT,
|
||||
ADD COLUMN content_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN content_reviewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN public.categories.content_status IS
|
||||
'Editorial review metadata only. Customer visibility does not yet filter this column.';
|
||||
COMMENT ON COLUMN public.question_packs.content_status IS
|
||||
'Editorial review metadata only. Customer visibility does not yet filter this column.';
|
||||
COMMENT ON COLUMN public.questions.content_status IS
|
||||
'Editorial review metadata only. Customer visibility does not yet filter this column.';
|
||||
COMMENT ON COLUMN public.dare_packs.content_status IS
|
||||
'Editorial review metadata only. Customer visibility does not yet filter this column.';
|
||||
COMMENT ON COLUMN public.dares.content_status IS
|
||||
'Editorial review metadata only. Customer visibility does not yet filter this column.';
|
||||
|
||||
CREATE INDEX categories_content_status_idx
|
||||
ON public.categories (content_status, sort_order);
|
||||
CREATE INDEX question_packs_content_status_idx
|
||||
ON public.question_packs (content_status, is_public, category_id, sort_order);
|
||||
CREATE INDEX questions_content_status_idx
|
||||
ON public.questions (content_status, pack_id, intensity)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX dare_packs_content_status_idx
|
||||
ON public.dare_packs (content_status, is_public, category_id, sort_order);
|
||||
CREATE INDEX dares_content_status_idx
|
||||
ON public.dares (content_status, pack_id, intensity);
|
||||
|
||||
CREATE TABLE public.content_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type public.content_entity_type NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
previous_status public.content_review_status NOT NULL,
|
||||
new_status public.content_review_status NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (length(btrim(reason)) > 0),
|
||||
changed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.content_reviews IS
|
||||
'Append-only audit history for reversible catalogue review decisions.';
|
||||
|
||||
CREATE INDEX content_reviews_entity_idx
|
||||
ON public.content_reviews (entity_type, entity_id, created_at DESC);
|
||||
CREATE INDEX content_reviews_created_at_idx
|
||||
ON public.content_reviews (created_at DESC);
|
||||
|
||||
ALTER TABLE public.content_reviews ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Supabase grants broad privileges to API roles through default privileges.
|
||||
-- Remove them before restoring the one intended authenticated capability.
|
||||
REVOKE ALL PRIVILEGES ON TABLE public.content_reviews FROM anon, authenticated;
|
||||
|
||||
CREATE POLICY "Super admins can view content review history"
|
||||
ON public.content_reviews
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.is_super_admin());
|
||||
|
||||
GRANT SELECT ON public.content_reviews TO authenticated;
|
||||
GRANT ALL ON public.content_reviews TO service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.prepare_new_content_review()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Every new catalogue row enters the review queue. Callers cannot combine
|
||||
-- creation and approval or forge reviewer metadata in one INSERT.
|
||||
NEW.content_status := 'unreviewed';
|
||||
NEW.content_review_reason := NULL;
|
||||
NEW.content_reviewed_at := NULL;
|
||||
NEW.content_reviewed_by := NULL;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.prepare_content_review_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
visible_column TEXT;
|
||||
visible_content_changed BOOLEAN := FALSE;
|
||||
explicit_status_change BOOLEAN :=
|
||||
NEW.content_status IS DISTINCT FROM OLD.content_status;
|
||||
explicit_reason_change BOOLEAN :=
|
||||
NEW.content_review_reason IS DISTINCT FROM OLD.content_review_reason;
|
||||
explicit_reviewer_metadata_change BOOLEAN :=
|
||||
NEW.content_reviewed_at IS DISTINCT FROM OLD.content_reviewed_at
|
||||
OR NEW.content_reviewed_by IS DISTINCT FROM OLD.content_reviewed_by;
|
||||
BEGIN
|
||||
FOREACH visible_column IN ARRAY TG_ARGV LOOP
|
||||
IF (to_jsonb(NEW) -> visible_column) IS DISTINCT FROM
|
||||
(to_jsonb(OLD) -> visible_column) THEN
|
||||
visible_content_changed := TRUE;
|
||||
EXIT;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- An explicit status/reason update is a review decision, including a
|
||||
-- same-status re-review with a new reason. Reviewer identity and time are
|
||||
-- always derived here rather than trusted from client input.
|
||||
IF explicit_status_change OR explicit_reason_change THEN
|
||||
IF auth.role() = 'anon'
|
||||
OR (auth.uid() IS NOT NULL AND NOT public.is_super_admin()) THEN
|
||||
RAISE EXCEPTION 'Only super admins can make catalogue review decisions';
|
||||
END IF;
|
||||
|
||||
IF explicit_status_change AND NOT explicit_reason_change THEN
|
||||
RAISE EXCEPTION 'A new content_review_reason is required when content_status changes';
|
||||
END IF;
|
||||
|
||||
IF NULLIF(btrim(NEW.content_review_reason), '') IS NULL THEN
|
||||
RAISE EXCEPTION 'A non-blank content_review_reason is required when content_status changes';
|
||||
END IF;
|
||||
|
||||
IF NEW.content_status = 'unreviewed' THEN
|
||||
NEW.content_reviewed_at := NULL;
|
||||
NEW.content_reviewed_by := NULL;
|
||||
ELSE
|
||||
NEW.content_reviewed_at := now();
|
||||
NEW.content_reviewed_by := auth.uid();
|
||||
END IF;
|
||||
-- A material content edit without an explicit review decision invalidates
|
||||
-- the prior decision. This path is available to ordinary content editors.
|
||||
ELSIF visible_content_changed THEN
|
||||
NEW.content_status := 'unreviewed';
|
||||
NEW.content_review_reason := 'Visible content changed; review required';
|
||||
NEW.content_reviewed_at := NULL;
|
||||
NEW.content_reviewed_by := NULL;
|
||||
ELSIF explicit_reviewer_metadata_change THEN
|
||||
-- Permit only the database-internal ON DELETE SET NULL action from the
|
||||
-- auth.users foreign key. JWT-backed anon/service calls have an auth role;
|
||||
-- direct client attempts therefore cannot use this exception.
|
||||
IF auth.uid() IS NULL
|
||||
AND auth.role() IS NULL
|
||||
AND NEW.content_reviewed_by IS NULL
|
||||
AND NEW.content_reviewed_at IS NOT DISTINCT FROM OLD.content_reviewed_at THEN
|
||||
NULL;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'content_reviewed_at and content_reviewed_by are database-managed';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.audit_content_review_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.content_status IS DISTINCT FROM OLD.content_status
|
||||
OR NEW.content_review_reason IS DISTINCT FROM OLD.content_review_reason THEN
|
||||
INSERT INTO public.content_reviews (
|
||||
entity_type,
|
||||
entity_id,
|
||||
previous_status,
|
||||
new_status,
|
||||
reason,
|
||||
changed_by
|
||||
) VALUES (
|
||||
TG_TABLE_NAME::public.content_entity_type,
|
||||
NEW.id,
|
||||
OLD.content_status,
|
||||
NEW.content_status,
|
||||
NEW.content_review_reason,
|
||||
auth.uid()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- These functions are trigger-only. Revoking direct execution keeps their
|
||||
-- SECURITY DEFINER privileges unavailable as a general authenticated API.
|
||||
REVOKE ALL ON FUNCTION public.prepare_content_review_change() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.audit_content_review_change() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.prepare_new_content_review() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.prepare_content_review_change() FROM anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.audit_content_review_change() FROM anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.prepare_new_content_review() FROM anon, authenticated;
|
||||
|
||||
CREATE TRIGGER categories_prepare_new_content_review
|
||||
BEFORE INSERT ON public.categories
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_new_content_review();
|
||||
CREATE TRIGGER question_packs_prepare_new_content_review
|
||||
BEFORE INSERT ON public.question_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_new_content_review();
|
||||
CREATE TRIGGER questions_prepare_new_content_review
|
||||
BEFORE INSERT ON public.questions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_new_content_review();
|
||||
CREATE TRIGGER dare_packs_prepare_new_content_review
|
||||
BEFORE INSERT ON public.dare_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_new_content_review();
|
||||
CREATE TRIGGER dares_prepare_new_content_review
|
||||
BEFORE INSERT ON public.dares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_new_content_review();
|
||||
|
||||
CREATE TRIGGER categories_prepare_content_review
|
||||
BEFORE UPDATE ON public.categories
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_content_review_change(
|
||||
'name', 'description', 'icon', 'color'
|
||||
);
|
||||
CREATE TRIGGER categories_audit_content_review
|
||||
AFTER UPDATE ON public.categories
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.audit_content_review_change();
|
||||
|
||||
CREATE TRIGGER question_packs_prepare_content_review
|
||||
BEFORE UPDATE ON public.question_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_content_review_change(
|
||||
'name', 'description', 'icon', 'category_id', 'is_explicit'
|
||||
);
|
||||
CREATE TRIGGER question_packs_audit_content_review
|
||||
AFTER UPDATE ON public.question_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.audit_content_review_change();
|
||||
|
||||
CREATE TRIGGER questions_prepare_content_review
|
||||
BEFORE UPDATE ON public.questions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_content_review_change(
|
||||
'text',
|
||||
'partner_text',
|
||||
'pack_id',
|
||||
'intensity',
|
||||
'question_type',
|
||||
'config',
|
||||
'required_props',
|
||||
'allowed_couple_genders',
|
||||
'target_user_genders',
|
||||
'inverse_of',
|
||||
'deleted_at'
|
||||
);
|
||||
CREATE TRIGGER questions_audit_content_review
|
||||
AFTER UPDATE ON public.questions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.audit_content_review_change();
|
||||
|
||||
CREATE TRIGGER dare_packs_prepare_content_review
|
||||
BEFORE UPDATE ON public.dare_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_content_review_change(
|
||||
'name', 'description', 'icon', 'category_id', 'is_explicit'
|
||||
);
|
||||
CREATE TRIGGER dare_packs_audit_content_review
|
||||
AFTER UPDATE ON public.dare_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.audit_content_review_change();
|
||||
|
||||
CREATE TRIGGER dares_prepare_content_review
|
||||
BEFORE UPDATE ON public.dares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.prepare_content_review_change(
|
||||
'text',
|
||||
'pack_id',
|
||||
'intensity',
|
||||
'suggested_duration_hours'
|
||||
);
|
||||
CREATE TRIGGER dares_audit_content_review
|
||||
AFTER UPDATE ON public.dares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.audit_content_review_change();
|
||||
|
||||
-- Stage the conservative archive set by immutable production IDs. This does not
|
||||
-- change is_public, deleted_at, RLS, RPC selection, or current application output.
|
||||
WITH archive_packs(id) AS (
|
||||
VALUES
|
||||
('9049a67a-a103-4ea3-8405-87dcaefc57ab'::UUID),
|
||||
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a'::UUID),
|
||||
('f0a2e7e5-554c-49c4-aa15-42d83b016349'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000003'::UUID),
|
||||
('40838dee-0af4-43c7-9d6c-6228d09f91bc'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000002'::UUID),
|
||||
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e'::UUID),
|
||||
('c7244576-f40d-4cdf-804a-788d2b9c1fce'::UUID),
|
||||
('7eabdfae-e70d-4be8-9b10-b43fee955312'::UUID),
|
||||
('374e10a6-6ae9-45c6-bee4-b8be0e617b79'::UUID),
|
||||
('dd143c55-c0a4-4884-a704-1d4d1325cbc2'::UUID),
|
||||
('035c47a6-858c-46fb-8ee3-84889399dca0'::UUID),
|
||||
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86'::UUID),
|
||||
('42331f68-60f5-4768-af26-b42c4e96c867'::UUID),
|
||||
('379d6a44-60a3-4ec9-98cc-eda46547cb23'::UUID),
|
||||
('e5aefe29-1ada-4fe5-a4ee-25342917f02c'::UUID),
|
||||
('bed94ed6-c065-4ff9-9d7f-02de6cade971'::UUID),
|
||||
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26'::UUID),
|
||||
('d18f6d4a-4a32-4687-8938-2a53a7d82739'::UUID),
|
||||
('b2df6b3b-4bb5-4baa-a29c-a3955187511c'::UUID),
|
||||
('d73e9818-4faa-4276-9a93-7c60cff9cdb1'::UUID),
|
||||
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb'::UUID),
|
||||
('f0f597b1-889c-4537-beeb-d0f459bb7907'::UUID),
|
||||
('46288aa4-271c-44cf-bfe2-d25662d3f9f8'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000001'::UUID),
|
||||
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73'::UUID),
|
||||
('42c00d6c-ab1f-42ce-afb0-00839064263b'::UUID),
|
||||
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c'::UUID),
|
||||
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00'::UUID),
|
||||
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba'::UUID)
|
||||
)
|
||||
UPDATE public.question_packs qp
|
||||
SET
|
||||
content_status = 'archived',
|
||||
content_review_reason = 'Conservative archive candidate following the 2026-02-03 store rejection',
|
||||
content_reviewed_at = now(),
|
||||
content_reviewed_by = NULL
|
||||
FROM archive_packs ap
|
||||
WHERE qp.id = ap.id;
|
||||
|
||||
WITH archive_packs(id) AS (
|
||||
VALUES
|
||||
('9049a67a-a103-4ea3-8405-87dcaefc57ab'::UUID),
|
||||
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a'::UUID),
|
||||
('f0a2e7e5-554c-49c4-aa15-42d83b016349'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000003'::UUID),
|
||||
('40838dee-0af4-43c7-9d6c-6228d09f91bc'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000002'::UUID),
|
||||
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e'::UUID),
|
||||
('c7244576-f40d-4cdf-804a-788d2b9c1fce'::UUID),
|
||||
('7eabdfae-e70d-4be8-9b10-b43fee955312'::UUID),
|
||||
('374e10a6-6ae9-45c6-bee4-b8be0e617b79'::UUID),
|
||||
('dd143c55-c0a4-4884-a704-1d4d1325cbc2'::UUID),
|
||||
('035c47a6-858c-46fb-8ee3-84889399dca0'::UUID),
|
||||
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86'::UUID),
|
||||
('42331f68-60f5-4768-af26-b42c4e96c867'::UUID),
|
||||
('379d6a44-60a3-4ec9-98cc-eda46547cb23'::UUID),
|
||||
('e5aefe29-1ada-4fe5-a4ee-25342917f02c'::UUID),
|
||||
('bed94ed6-c065-4ff9-9d7f-02de6cade971'::UUID),
|
||||
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26'::UUID),
|
||||
('d18f6d4a-4a32-4687-8938-2a53a7d82739'::UUID),
|
||||
('b2df6b3b-4bb5-4baa-a29c-a3955187511c'::UUID),
|
||||
('d73e9818-4faa-4276-9a93-7c60cff9cdb1'::UUID),
|
||||
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb'::UUID),
|
||||
('f0f597b1-889c-4537-beeb-d0f459bb7907'::UUID),
|
||||
('46288aa4-271c-44cf-bfe2-d25662d3f9f8'::UUID),
|
||||
('d2000000-0000-0000-0000-000000000001'::UUID),
|
||||
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73'::UUID),
|
||||
('42c00d6c-ab1f-42ce-afb0-00839064263b'::UUID),
|
||||
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c'::UUID),
|
||||
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00'::UUID),
|
||||
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba'::UUID)
|
||||
)
|
||||
UPDATE public.questions q
|
||||
SET
|
||||
content_status = 'archived',
|
||||
content_review_reason = 'Archived with its pack pending rewrite',
|
||||
content_reviewed_at = now(),
|
||||
content_reviewed_by = NULL
|
||||
FROM archive_packs ap
|
||||
WHERE q.pack_id = ap.id;
|
||||
@@ -0,0 +1,381 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { assertEquals, assertMatch } from "std/assert/mod.ts";
|
||||
import {
|
||||
adminClient,
|
||||
createTestUser,
|
||||
SUPABASE_URL,
|
||||
} from "./utils.ts";
|
||||
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
|
||||
if (!anonKey) {
|
||||
throw new Error("SUPABASE_ANON_KEY is required");
|
||||
}
|
||||
|
||||
Deno.test("catalogue review migration stages the complete audited archive set", async () => {
|
||||
const migrationSql = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../migrations/20260827215907_add_catalog_content_review_metadata.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const archiveBlocks = [...migrationSql.matchAll(
|
||||
/WITH archive_packs\(id\) AS \(\s*VALUES([\s\S]*?)\)\s*UPDATE/g,
|
||||
)];
|
||||
|
||||
assertEquals(archiveBlocks.length, 2);
|
||||
|
||||
const extractIds = (block: string) =>
|
||||
[...block.matchAll(
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
|
||||
)].map(([id]) => id);
|
||||
const packIds = extractIds(archiveBlocks[0][1]);
|
||||
const questionPackIds = extractIds(archiveBlocks[1][1]);
|
||||
|
||||
assertEquals(packIds.length, 30);
|
||||
assertEquals(new Set(packIds).size, 30);
|
||||
assertEquals(questionPackIds, packIds);
|
||||
|
||||
for (
|
||||
const auditedMixedPackId of [
|
||||
"f0f597b1-889c-4537-beeb-d0f459bb7907", // Body Mapping
|
||||
"46288aa4-271c-44cf-bfe2-d25662d3f9f8", // Date Night Sparks
|
||||
"d2000000-0000-0000-0000-000000000001", // First Impressions
|
||||
"07f0da41-8ef7-4a48-8ec9-cef2dabaee73", // Love Notes
|
||||
"42c00d6c-ab1f-42ce-afb0-00839064263b", // Romantic Gestures
|
||||
"8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c", // Sensual Massage
|
||||
"d5d1b8d2-a868-4150-86bc-da3f8e3e8b00", // Testing the Waters
|
||||
"19e4ddbd-a4aa-4b26-80bb-53b7187f0eba", // Missing You
|
||||
]
|
||||
) {
|
||||
assertEquals(packIds.includes(auditedMixedPackId), true);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("catalogue review metadata is reversible, audited, and non-super-admin decisions are rejected", async () => {
|
||||
const suffix = crypto.randomUUID();
|
||||
const email = `content-review-${suffix}@example.com`;
|
||||
let packId: string | undefined;
|
||||
let insertedPackId: string | undefined;
|
||||
let userId: string | undefined;
|
||||
|
||||
try {
|
||||
const { data: pack, error: packError } = await adminClient
|
||||
.from("question_packs")
|
||||
.insert({
|
||||
name: `Content review test ${suffix}`,
|
||||
is_public: true,
|
||||
is_premium: false,
|
||||
is_explicit: false,
|
||||
})
|
||||
.select("id, content_status")
|
||||
.single();
|
||||
|
||||
if (packError) throw packError;
|
||||
packId = pack.id;
|
||||
assertEquals(pack.content_status, "unreviewed");
|
||||
|
||||
const { data: archived, error: archiveError } = await adminClient
|
||||
.from("question_packs")
|
||||
.update({
|
||||
content_status: "archived",
|
||||
content_review_reason: "Test archive decision",
|
||||
})
|
||||
.eq("id", packId)
|
||||
.select("content_status, content_review_reason")
|
||||
.single();
|
||||
|
||||
if (archiveError) throw archiveError;
|
||||
assertEquals(archived.content_status, "archived");
|
||||
assertEquals(archived.content_review_reason, "Test archive decision");
|
||||
|
||||
const { error: blankReasonError } = await adminClient
|
||||
.from("question_packs")
|
||||
.update({
|
||||
content_status: "allowed",
|
||||
content_review_reason: " ",
|
||||
})
|
||||
.eq("id", packId);
|
||||
if (!blankReasonError) {
|
||||
throw new Error("Expected blank review reason to be rejected");
|
||||
}
|
||||
assertMatch(blankReasonError.message, /non-blank content_review_reason/i);
|
||||
|
||||
const user = await createTestUser(email);
|
||||
userId = user.id;
|
||||
|
||||
const userClient = createClient(SUPABASE_URL, anonKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
const { error: signInError } = await userClient.auth.signInWithPassword({
|
||||
email,
|
||||
password: "password123",
|
||||
});
|
||||
if (signInError) throw signInError;
|
||||
|
||||
// The metadata-only migration must not hide content from existing clients.
|
||||
const { data: stillVisible, error: visibilityError } = await userClient
|
||||
.from("question_packs")
|
||||
.select("id, content_status")
|
||||
.eq("id", packId)
|
||||
.single();
|
||||
if (visibilityError) throw visibilityError;
|
||||
assertEquals(stillVisible.id, packId);
|
||||
assertEquals(stillVisible.content_status, "archived");
|
||||
|
||||
const { error: adminError } = await adminClient.from("admin_users").insert({
|
||||
user_id: userId,
|
||||
role: "pack_creator",
|
||||
});
|
||||
if (adminError) throw adminError;
|
||||
|
||||
const { error: forbiddenDecision } = await userClient
|
||||
.from("question_packs")
|
||||
.update({
|
||||
content_status: "allowed",
|
||||
content_review_reason: "Pack creator should not approve content",
|
||||
})
|
||||
.eq("id", packId);
|
||||
|
||||
if (!forbiddenDecision) {
|
||||
throw new Error("Expected pack creator catalogue decision to be rejected");
|
||||
}
|
||||
assertMatch(forbiddenDecision.message, /Only super admins/i);
|
||||
|
||||
const { error: promoteError } = await adminClient
|
||||
.from("admin_users")
|
||||
.update({ role: "super_admin" })
|
||||
.eq("user_id", userId);
|
||||
if (promoteError) throw promoteError;
|
||||
|
||||
const { data: allowed, error: allowedError } = await userClient
|
||||
.from("question_packs")
|
||||
.update({
|
||||
content_status: "allowed",
|
||||
content_review_reason: "Test super admin approval",
|
||||
})
|
||||
.eq("id", packId)
|
||||
.select("content_status, content_reviewed_by")
|
||||
.single();
|
||||
if (allowedError) throw allowedError;
|
||||
assertEquals(allowed.content_status, "allowed");
|
||||
assertEquals(allowed.content_reviewed_by, userId);
|
||||
|
||||
const { data: rereviewed, error: rereviewError } = await userClient
|
||||
.from("question_packs")
|
||||
.update({ content_review_reason: "Test approval independently rechecked" })
|
||||
.eq("id", packId)
|
||||
.select("content_status, content_review_reason, content_reviewed_by")
|
||||
.single();
|
||||
if (rereviewError) throw rereviewError;
|
||||
assertEquals(rereviewed.content_status, "allowed");
|
||||
assertEquals(
|
||||
rereviewed.content_review_reason,
|
||||
"Test approval independently rechecked",
|
||||
);
|
||||
assertEquals(rereviewed.content_reviewed_by, userId);
|
||||
|
||||
const { error: demoteError } = await adminClient
|
||||
.from("admin_users")
|
||||
.update({ role: "pack_creator" })
|
||||
.eq("user_id", userId);
|
||||
if (demoteError) throw demoteError;
|
||||
|
||||
const { error: forgedReason } = await userClient
|
||||
.from("question_packs")
|
||||
.update({ content_review_reason: "Pack creator forged review reason" })
|
||||
.eq("id", packId);
|
||||
if (!forgedReason) {
|
||||
throw new Error("Expected pack creator review metadata change to be rejected");
|
||||
}
|
||||
assertMatch(forgedReason.message, /Only super admins/i);
|
||||
|
||||
const { error: forgedReviewer } = await userClient
|
||||
.from("question_packs")
|
||||
.update({ content_reviewed_by: null })
|
||||
.eq("id", packId);
|
||||
if (!forgedReviewer) {
|
||||
throw new Error("Expected database-managed reviewer metadata to be rejected");
|
||||
}
|
||||
assertMatch(forgedReviewer.message, /database-managed/i);
|
||||
|
||||
const { data: guardedInsert, error: guardedInsertError } = await userClient
|
||||
.from("question_packs")
|
||||
.insert({
|
||||
name: `Guarded content review insert ${suffix}`,
|
||||
is_public: true,
|
||||
is_premium: false,
|
||||
is_explicit: false,
|
||||
content_status: "allowed",
|
||||
content_review_reason: "Forged approval on insert",
|
||||
content_reviewed_at: new Date().toISOString(),
|
||||
content_reviewed_by: userId,
|
||||
})
|
||||
.select(
|
||||
"id, content_status, content_review_reason, content_reviewed_at, content_reviewed_by",
|
||||
)
|
||||
.single();
|
||||
if (guardedInsertError) throw guardedInsertError;
|
||||
insertedPackId = guardedInsert.id;
|
||||
assertEquals(guardedInsert.content_status, "unreviewed");
|
||||
assertEquals(guardedInsert.content_review_reason, null);
|
||||
assertEquals(guardedInsert.content_reviewed_at, null);
|
||||
assertEquals(guardedInsert.content_reviewed_by, null);
|
||||
|
||||
const { data: edited, error: editError } = await userClient
|
||||
.from("question_packs")
|
||||
.update({ name: `Edited content review test ${suffix}` })
|
||||
.eq("id", packId)
|
||||
.select("content_status, content_review_reason")
|
||||
.single();
|
||||
|
||||
if (editError) throw editError;
|
||||
assertEquals(edited.content_status, "unreviewed");
|
||||
assertEquals(
|
||||
edited.content_review_reason,
|
||||
"Visible content changed; review required",
|
||||
);
|
||||
|
||||
const { data: reviews, error: reviewsError } = await adminClient
|
||||
.from("content_reviews")
|
||||
.select("previous_status, new_status, reason, changed_by")
|
||||
.eq("entity_type", "question_packs")
|
||||
.eq("entity_id", packId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (reviewsError) throw reviewsError;
|
||||
assertEquals(reviews.length, 4);
|
||||
assertEquals(reviews[0].previous_status, "unreviewed");
|
||||
assertEquals(reviews[0].new_status, "archived");
|
||||
assertEquals(reviews[1].previous_status, "archived");
|
||||
assertEquals(reviews[1].new_status, "allowed");
|
||||
assertEquals(reviews[1].changed_by, userId);
|
||||
assertEquals(reviews[2].previous_status, "allowed");
|
||||
assertEquals(reviews[2].new_status, "allowed");
|
||||
assertEquals(reviews[2].changed_by, userId);
|
||||
assertEquals(reviews[3].previous_status, "allowed");
|
||||
assertEquals(reviews[3].new_status, "unreviewed");
|
||||
assertEquals(reviews[3].changed_by, userId);
|
||||
|
||||
const { data: hiddenReviews, error: hiddenReviewsError } = await userClient
|
||||
.from("content_reviews")
|
||||
.select("id")
|
||||
.eq("entity_type", "question_packs")
|
||||
.eq("entity_id", packId);
|
||||
if (hiddenReviewsError) throw hiddenReviewsError;
|
||||
assertEquals(hiddenReviews.length, 0);
|
||||
|
||||
const { error: forgedAuditInsert } = await userClient
|
||||
.from("content_reviews")
|
||||
.insert({
|
||||
entity_type: "question_packs",
|
||||
entity_id: packId,
|
||||
previous_status: "unreviewed",
|
||||
new_status: "allowed",
|
||||
reason: "Forged audit event",
|
||||
});
|
||||
if (!forgedAuditInsert) {
|
||||
throw new Error("Expected authenticated audit insertion to be rejected");
|
||||
}
|
||||
} finally {
|
||||
if (insertedPackId) {
|
||||
await adminClient
|
||||
.from("question_packs")
|
||||
.delete()
|
||||
.eq("id", insertedPackId);
|
||||
}
|
||||
if (packId) {
|
||||
await adminClient
|
||||
.from("content_reviews")
|
||||
.delete()
|
||||
.eq("entity_type", "question_packs")
|
||||
.eq("entity_id", packId);
|
||||
await adminClient.from("question_packs").delete().eq("id", packId);
|
||||
}
|
||||
if (userId) {
|
||||
await adminClient.from("admin_users").delete().eq("user_id", userId);
|
||||
await adminClient.auth.admin.deleteUser(userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("deleting a reviewer preserves the decision and clears its current reviewer reference", async () => {
|
||||
const suffix = crypto.randomUUID();
|
||||
const email = `deleted-content-reviewer-${suffix}@example.com`;
|
||||
let packId: string | undefined;
|
||||
let userId: string | undefined;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(email);
|
||||
userId = user.id;
|
||||
|
||||
const { error: adminError } = await adminClient.from("admin_users").insert({
|
||||
user_id: userId,
|
||||
role: "super_admin",
|
||||
});
|
||||
if (adminError) throw adminError;
|
||||
|
||||
const reviewerClient = createClient(SUPABASE_URL, anonKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
const { error: signInError } = await reviewerClient.auth.signInWithPassword({
|
||||
email,
|
||||
password: "password123",
|
||||
});
|
||||
if (signInError) throw signInError;
|
||||
|
||||
const { data: pack, error: packError } = await adminClient
|
||||
.from("question_packs")
|
||||
.insert({
|
||||
name: `Reviewer deletion test ${suffix}`,
|
||||
is_public: true,
|
||||
is_premium: false,
|
||||
is_explicit: false,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
if (packError) throw packError;
|
||||
packId = pack.id;
|
||||
|
||||
const { error: approveError } = await reviewerClient
|
||||
.from("question_packs")
|
||||
.update({
|
||||
content_status: "allowed",
|
||||
content_review_reason: "Approval survives reviewer deletion",
|
||||
})
|
||||
.eq("id", packId);
|
||||
if (approveError) throw approveError;
|
||||
|
||||
const { error: deleteUserError } = await adminClient.auth.admin.deleteUser(
|
||||
userId,
|
||||
);
|
||||
if (deleteUserError) throw deleteUserError;
|
||||
userId = undefined;
|
||||
|
||||
const { data: afterDeletion, error: afterDeletionError } = await adminClient
|
||||
.from("question_packs")
|
||||
.select("content_status, content_review_reason, content_reviewed_by")
|
||||
.eq("id", packId)
|
||||
.single();
|
||||
if (afterDeletionError) throw afterDeletionError;
|
||||
assertEquals(afterDeletion.content_status, "allowed");
|
||||
assertEquals(
|
||||
afterDeletion.content_review_reason,
|
||||
"Approval survives reviewer deletion",
|
||||
);
|
||||
assertEquals(afterDeletion.content_reviewed_by, null);
|
||||
} finally {
|
||||
if (packId) {
|
||||
await adminClient
|
||||
.from("content_reviews")
|
||||
.delete()
|
||||
.eq("entity_type", "question_packs")
|
||||
.eq("entity_id", packId);
|
||||
await adminClient.from("question_packs").delete().eq("id", packId);
|
||||
}
|
||||
if (userId) {
|
||||
await adminClient.from("admin_users").delete().eq("user_id", userId);
|
||||
await adminClient.auth.admin.deleteUser(userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user