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>>
}
@@ -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
}
}
+1 -28
View File
@@ -1,33 +1,6 @@
import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from './SubmissionRepository'
import { supabase } from './supabaseClient'
interface SubmissionRow {
id: string
entity_type: CatalogEntityType
entity_id: string
proposed_data: Record<string, unknown>
submitter_id: string
status: CatalogSubmission['status']
reviewer_id: string | null
review_reason: string | null
created_at: string
updated_at: string
}
function toSubmission(row: SubmissionRow): CatalogSubmission {
return {
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
proposedData: row.proposed_data,
submitterId: row.submitter_id,
status: row.status,
reviewerId: row.reviewer_id ?? undefined,
reviewReason: row.review_reason ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
import { toSubmission, type SubmissionRow } from './submissionRowMapping'
/** Backs the app with the `catalog_submissions` table. RLS already scopes
* every query/write here to what a regular (non-Admin) user should be able
+32
View File
@@ -0,0 +1,32 @@
import type { CatalogEntityType, CatalogSubmission } from './SubmissionRepository'
/** Shared by SupabaseSubmissionRepository (submitter's-eye view) and
* SupabaseAdminSubmissionRepository (Admin's-eye view) — both read/write
* the same `catalog_submissions` table shape. */
export interface SubmissionRow {
id: string
entity_type: CatalogEntityType
entity_id: string
proposed_data: Record<string, unknown>
submitter_id: string
status: CatalogSubmission['status']
reviewer_id: string | null
review_reason: string | null
created_at: string
updated_at: string
}
export function toSubmission(row: SubmissionRow): CatalogSubmission {
return {
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
proposedData: row.proposed_data,
submitterId: row.submitter_id,
status: row.status,
reviewerId: row.reviewer_id ?? undefined,
reviewReason: row.review_reason ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}