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
@@ -0,0 +1,125 @@
import { v4 as uuid } from 'uuid'
import type { AdminSubmissionRepository, UsageImpact, UsageImpactSample } from './AdminSubmissionRepository'
import type { CatalogSubmission } from './SubmissionRepository'
import { supabase } from './supabaseClient'
import { toSubmission, type SubmissionRow } from './submissionRowMapping'
const TABLE_BY_ENTITY_TYPE: Record<CatalogSubmission['entityType'], string> = {
device_template: 'device_templates',
port_type: 'port_types',
cable_type: 'cable_types',
device_category: 'device_categories',
}
interface ProposedDeviceTemplatePort {
name: string
direction: string
port_type_id: string
}
/** Backs the app with the `catalog_submissions` table (Admin's-eye view)
* plus the catalog tables it approves onto and the usage-impact RPC. RLS's
* `is_admin()` clauses are what actually gate every write here — this
* class assumes the caller already is one. */
export class SupabaseAdminSubmissionRepository implements AdminSubmissionRepository {
async listAll(): Promise<CatalogSubmission[]> {
const { data, error } = await supabase
.from('catalog_submissions')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Failed to load submissions from Supabase', error)
return []
}
return ((data ?? []) as SubmissionRow[]).map(toSubmission)
}
async approve(submission: CatalogSubmission): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
// proposed_data is already row-shaped (see data/catalogRowMapping.ts) —
// approving is just writing it onto the live row, flipping it public.
// `ports` (device_template only) isn't a column on device_templates
// itself; pull it out and replace device_template_ports separately.
const { ports, ...rowPatch } = submission.proposedData as Record<string, unknown> & { ports?: ProposedDeviceTemplatePort[] }
const table = TABLE_BY_ENTITY_TYPE[submission.entityType]
const { error } = await supabase
.from(table)
.update({ ...rowPatch, is_public: true, owner_id: null })
.eq('id', submission.entityId)
if (error) {
console.error('Failed to approve submission (entity update) in Supabase', error)
throw error
}
if (submission.entityType === 'device_template') {
const { error: deleteError } = await supabase
.from('device_template_ports')
.delete()
.eq('device_template_id', submission.entityId)
if (deleteError) console.error('Failed to clear device template ports while approving', deleteError)
if (ports && ports.length > 0) {
const { error: insertError } = await supabase.from('device_template_ports').insert(
ports.map((port, index) => ({
id: uuid(),
device_template_id: submission.entityId,
name: port.name,
direction: port.direction,
port_type_id: port.port_type_id,
sort_order: index,
})),
)
if (insertError) console.error('Failed to write device template ports while approving', insertError)
}
}
const { error: statusError } = await supabase
.from('catalog_submissions')
.update({ status: 'approved', reviewer_id: user.id, review_reason: null })
.eq('id', submission.id)
if (statusError) console.error('Failed to mark submission approved in Supabase', statusError)
}
async reject(id: string, reason: string): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
const { error } = await supabase
.from('catalog_submissions')
.update({ status: 'rejected', reviewer_id: user.id, review_reason: reason })
.eq('id', id)
if (error) console.error('Failed to reject submission in Supabase', error)
}
async getUsageImpact(entityType: CatalogSubmission['entityType'], entityId: string): Promise<UsageImpact> {
const { data, error } = await supabase.rpc('catalog_entity_usage_impact', {
p_entity_type: entityType,
p_entity_id: entityId,
})
if (error || !data || data.length === 0) {
if (error) console.error('Failed to load usage impact from Supabase', error)
return { diagramCount: 0, sample: [] }
}
const row = data[0] as { diagram_count: number; sample: UsageImpactSample[] }
return { diagramCount: row.diagram_count, sample: row.sample ?? [] }
}
async getSubmitterUsernames(submissionIds: string[]): Promise<Record<string, string>> {
if (submissionIds.length === 0) return {}
const { data, error } = await supabase.rpc('catalog_submission_submitters', { p_submission_ids: submissionIds })
if (error) {
console.error('Failed to load submitter usernames from Supabase', error)
return {}
}
const result: Record<string, string> = {}
for (const row of (data ?? []) as Array<{ submission_id: string; username: string }>) {
result[row.submission_id] = row.username
}
return result
}
}