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:
2026-09-08 13:01:56 -05:00
parent a0c598cb1b
commit 1f8d49345e
16 changed files with 1048 additions and 98 deletions
+39
View File
@@ -0,0 +1,39 @@
import type { CatalogSubmission } from './SubmissionRepository'
/** One entry in a usage-impact sample — never raw diagram content, just
* enough to identify it (see organized-ideas.md §3's impact-check). */
export interface UsageImpactSample {
id: string
name: string
ownerUsername: string
}
export interface UsageImpact {
/** Total diagrams referencing this entity — not capped, unlike `sample`. */
diagramCount: number
/** Up to 5 of the most recently updated referencing diagrams. */
sample: UsageImpactSample[]
}
/** Storage abstraction for the Admin's-eye view of the catalog submission
* queue — mirrors SubmissionRepository's role for the submitter's-eye
* view. Every method here relies on the caller actually being an Admin;
* RLS (and, for the impact function, an explicit is_admin() check) is the
* real enforcement, not this interface. */
export interface AdminSubmissionRepository {
/** Every submission across all users, most recent first. */
listAll(): Promise<CatalogSubmission[]>
/** Approves a submission: writes its proposed_data onto the live row at
* entity_id — promoting it to public in place if it wasn't already —
* and marks the submission approved. Same id throughout; never creates
* a duplicate public row (organized-ideas.md §3). */
approve(submission: CatalogSubmission): Promise<void>
/** Rejects a submission with a reason the submitter will see. */
reject(id: string, reason: string): Promise<void>
/** How many diagrams reference this entity, and a small sample — see
* organized-ideas.md §3's impact-check-before-editing. */
getUsageImpact(entityType: CatalogSubmission['entityType'], entityId: string): Promise<UsageImpact>
/** Submitter username per submission id, for the ones a username could
* be resolved for (batched — one round trip for a whole queue listing). */
getSubmitterUsernames(submissionIds: string[]): Promise<Record<string, string>>
}