Add the Admin review queue
Lets an Admin/Super-Admin review pending catalog submissions and approve (in place, same id) or reject (with a required reason) them, per organized-ideas.md §3/§9. Backend (supabase/migrations/20260910010000_admin_review_queue.sql): - Per-user pending-submission cap (10), enforced in catalog_submissions' insert policy rather than trusted to the client. - catalog_entity_usage_impact(entity_type, entity_id): a SECURITY DEFINER, admin-gated aggregate function answering "how many diagrams reference this, and a short sample" by scanning diagrams.data JSONB — never raw diagram content, and available to regular Admins even though they don't otherwise have diagram visibility (only Super Admins do, per §6). - catalog_submission_submitters(ids[]): same admin-gated pattern, batched, so the queue can show who submitted something without opening general profile browsing to regular Admins. - Follow-up migration: a rejected submission had no way out (the delete policy only allowed withdrawing 'pending') — extended to allow 'rejected' too, so a submitter can dismiss one they don't intend to revise. - 12 new pgTAP tests (38/38 total) covering the cap, both privileged functions (including the non-admin-gets-rejected case), and withdrawing pending vs. rejected submissions. Frontend: - authStore: minimal role awareness, replacing TopBar's local username fetch, used to gate the Review Queue UI. - AdminSubmissionRepository/SupabaseAdminSubmissionRepository + adminReviewStore: list all submissions, approve/reject, usage impact, submitter usernames. - AdminReviewModal: per-submission diff view (current vs. proposed, both row-shaped via the existing catalog<->row mappers), a duplicate-detection nudge (Levenshtein distance against existing public device names) for new device submissions, and an inline impact-check for edits to already-public entries before approving. - TopBar: role-gated "Review Queue" button with a pending-count badge; "My Submissions" gets an unseen-outcome badge (localStorage-tracked, like the existing hidden-template preference) so a submitter notices a decision without having to keep reopening the modal. - Deliberately deferred: the site-wide announcement banner (its own follow-up, per discussion) and the Admin/Super-Admin role-assignment UI (§9's later phase — becoming an Admin locally still means setting profiles.role via SQL/Studio). Verified: tsc -b and oxlint clean; supabase db reset + 38/38 pgTAP tests pass; confirmed the two new RPC functions are actually reachable through PostgREST (not just raw SQL) via a live curl call; manually tested submit -> review -> approve/reject -> (for rejected) dismiss end to end.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
-- Admin review queue support, per organized-ideas.md §3:
|
||||
-- * a soft per-user cap on pending submissions (abuse prevention, §2)
|
||||
-- * an impact-check an Admin can run before approving an edit to an
|
||||
-- already-public entry — "how many diagrams reference this, and a short
|
||||
-- sample" — computed by a privileged, aggregate-only function so regular
|
||||
-- Admins (who don't have diagram visibility, only Super Admins do, per
|
||||
-- §6) never see raw diagram content, just the blast-radius numbers.
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Per-user pending-submission cap (10 — organized-ideas.md §3's "exact
|
||||
-- number TBD when this is built").
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
drop policy "catalog_submissions_insert" on public.catalog_submissions;
|
||||
|
||||
create policy "catalog_submissions_insert" on public.catalog_submissions for insert
|
||||
with check (
|
||||
submitter_id = auth.uid()
|
||||
and status = 'pending'
|
||||
and (
|
||||
select count(*) from public.catalog_submissions
|
||||
where submitter_id = auth.uid() and status = 'pending'
|
||||
) < 10
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Usage-impact aggregate function.
|
||||
--
|
||||
-- A diagram's `data` JSONB mirrors the exported Project shape (see
|
||||
-- data/exportImport.ts / domain/types.ts): devices[].templateId,
|
||||
-- devices[].category, devices[].ports[].portTypeId, and
|
||||
-- connections[].cableTypeId are the four places a catalog entity id can be
|
||||
-- referenced. security definer so it can read every diagram regardless of
|
||||
-- the caller's own diagrams RLS visibility — the is_admin() check below is
|
||||
-- what keeps this from being an open door, and the return shape (a count
|
||||
-- plus up to 5 {id, name, ownerUsername} samples) is deliberately far short
|
||||
-- of full diagram content.
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
|
||||
returns table(diagram_count integer, sample jsonb)
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
if not public.is_admin() then
|
||||
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||
end if;
|
||||
|
||||
return query
|
||||
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
|
||||
from (
|
||||
select d.id, d.name, p.username as owner_username,
|
||||
row_number() over (order by d.updated_at desc) as rn
|
||||
from public.diagrams d
|
||||
join public.profiles p on p.id = d.owner_id
|
||||
where case p_entity_type
|
||||
when 'device_template' then exists (
|
||||
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
|
||||
where dev ->> 'templateId' = p_entity_id
|
||||
)
|
||||
when 'device_category' then exists (
|
||||
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
|
||||
where dev ->> 'category' = p_entity_id
|
||||
)
|
||||
when 'port_type' then exists (
|
||||
select 1
|
||||
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
|
||||
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
|
||||
where port ->> 'portTypeId' = p_entity_id
|
||||
)
|
||||
when 'cable_type' then exists (
|
||||
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
|
||||
where conn ->> 'cableTypeId' = p_entity_id
|
||||
)
|
||||
else false
|
||||
end
|
||||
) s;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Batched submitter-username lookup for the review queue list.
|
||||
--
|
||||
-- profiles_select_self_or_super_admin deliberately keeps a regular Admin
|
||||
-- from browsing other users' profiles directly — but an Admin reviewing a
|
||||
-- submission already sees its content, so knowing *who* submitted it isn't
|
||||
-- a bigger exposure than the usage-impact function's owner usernames
|
||||
-- above; it's just gated the same way (admin-only, minimal fields, no
|
||||
-- broader profile browsing). Batched (array in, rows out) so listing a
|
||||
-- whole queue costs one round trip, not one per submission.
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
create or replace function public.catalog_submission_submitters(p_submission_ids uuid[])
|
||||
returns table(submission_id uuid, username text)
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
if not public.is_admin() then
|
||||
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||
end if;
|
||||
|
||||
return query
|
||||
select s.id, p.username
|
||||
from public.catalog_submissions s
|
||||
join public.profiles p on p.id = s.submitter_id
|
||||
where s.id = any(p_submission_ids);
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A rejected submission previously had no way out: the delete policy only
|
||||
-- allowed withdrawing a still-pending one, but the UI (and organized-ideas.md
|
||||
-- §3's "rejected submissions stay editable for resubmission") never intended
|
||||
-- rejected to mean "stuck forever" — a submitter who decides not to pursue a
|
||||
-- rejected submission further should be able to dismiss it, same as pulling
|
||||
-- back a pending one.
|
||||
drop policy "catalog_submissions_delete" on public.catalog_submissions;
|
||||
|
||||
create policy "catalog_submissions_delete" on public.catalog_submissions for delete
|
||||
using (submitter_id = auth.uid() and status in ('pending', 'rejected'));
|
||||
+149
-3
@@ -25,7 +25,7 @@ begin;
|
||||
|
||||
create extension if not exists pgtap with schema extensions;
|
||||
|
||||
select plan(23);
|
||||
select plan(38);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Fixtures (as postgres — RLS does not apply)
|
||||
@@ -205,6 +205,26 @@ select lives_ok(
|
||||
'bob can submit a new catalog entry for review'
|
||||
);
|
||||
|
||||
-- Fill the rest of bob's pending-submission cap (organized-ideas.md §3's
|
||||
-- soft cap, set to 10) and confirm the 11th is rejected.
|
||||
insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
||||
select 'device_category', jsonb_build_object('name', 'Bob Cap Filler ' || g), '22222222-2222-2222-2222-222222222222'
|
||||
from generate_series(1, 9) g;
|
||||
|
||||
select is(
|
||||
(select count(*)::int from public.catalog_submissions
|
||||
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'),
|
||||
10,
|
||||
'bob has filled his pending-submission cap (10)'
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
||||
values ('device_category', '{"name":"One Too Many"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$,
|
||||
'42501'::char(5), null,
|
||||
'bob cannot exceed the pending-submission cap'
|
||||
);
|
||||
|
||||
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||
|
||||
select is(
|
||||
@@ -215,10 +235,53 @@ select is(
|
||||
|
||||
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||
|
||||
-- 10, not 1: includes the 9 cap-filler submissions inserted above.
|
||||
select is(
|
||||
(select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'),
|
||||
1,
|
||||
'carol (admin) can see bob''s submission'
|
||||
10,
|
||||
'carol (admin) can see all of bob''s submissions'
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Withdrawing a submission — pending or rejected. A rejected submission
|
||||
-- previously had no way out (only 'pending' was deletable); it should be
|
||||
-- dismissable the same as a pending one, not stuck forever.
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
select lives_ok(
|
||||
$$ update public.catalog_submissions
|
||||
set status = 'rejected', reviewer_id = '33333333-3333-3333-3333-333333333333', review_reason = 'Needs more detail'
|
||||
where id = (
|
||||
select id from public.catalog_submissions
|
||||
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
||||
order by created_at limit 1
|
||||
) $$,
|
||||
'carol (admin) can reject one of bob''s pending submissions'
|
||||
);
|
||||
|
||||
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||
|
||||
select lives_ok(
|
||||
$$ delete from public.catalog_submissions
|
||||
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected' $$,
|
||||
'bob can withdraw (delete) his rejected submission'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select count(*)::int from public.catalog_submissions
|
||||
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected'),
|
||||
0,
|
||||
'the rejected submission is gone after withdrawal'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ delete from public.catalog_submissions
|
||||
where id = (
|
||||
select id from public.catalog_submissions
|
||||
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
||||
limit 1
|
||||
) $$,
|
||||
'bob can still withdraw a pending submission (unchanged behavior)'
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
@@ -240,6 +303,89 @@ select lives_ok(
|
||||
'dave (super admin) can change another user''s role'
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Usage-impact function (organized-ideas.md §3's impact-check-before-editing:
|
||||
-- an Admin can see the blast radius of a catalog edit without being able to
|
||||
-- see the diagrams themselves).
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.diagrams (id, name, owner_id, data)
|
||||
values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111',
|
||||
'{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$,
|
||||
'alice can insert a diagram referencing test catalog ids for the impact-check test'
|
||||
);
|
||||
|
||||
-- bob, not alice, for this check: alice was promoted to admin by the role-
|
||||
-- escalation test above, so she'd no longer be a useful "regular user" case.
|
||||
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||
|
||||
select throws_ok(
|
||||
$$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$,
|
||||
'42501'::char(5), null,
|
||||
'bob (regular user) cannot call the usage-impact function'
|
||||
);
|
||||
|
||||
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||
|
||||
select is(
|
||||
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
||||
1,
|
||||
'carol (admin) sees the correct impact count for a device template, despite having no direct visibility into that diagram'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select sample -> 0 ->> 'ownerUsername' from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
||||
'alice',
|
||||
'the impact sample identifies the diagram by name/owner username, not raw diagram content'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select diagram_count from public.catalog_entity_usage_impact('port_type', 'pt-impact-test-port')),
|
||||
1,
|
||||
'carol (admin) sees the correct impact count for a port type'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select diagram_count from public.catalog_entity_usage_impact('cable_type', 'ct-impact-test-cable')),
|
||||
1,
|
||||
'carol (admin) sees the correct impact count for a cable type'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
|
||||
0,
|
||||
'the impact count is zero for an entity id referenced by nothing'
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------------
|
||||
-- Batched submitter-username lookup (gated the same way as the impact
|
||||
-- function above — an Admin reviewing a submission can see who submitted
|
||||
-- it, without a general ability to browse other users' profiles).
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||
|
||||
select throws_ok(
|
||||
$$ select * from public.catalog_submission_submitters(
|
||||
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
||||
) $$,
|
||||
'42501'::char(5), null,
|
||||
'bob (regular user) cannot look up submitter usernames'
|
||||
);
|
||||
|
||||
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||
|
||||
select is(
|
||||
(select username from public.catalog_submission_submitters(
|
||||
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
||||
)),
|
||||
'bob',
|
||||
'carol (admin) can look up the submitter''s username for a submission she can review'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
|
||||
rollback;
|
||||
|
||||
Reference in New Issue
Block a user