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
+14 -1
View File
@@ -1,4 +1,6 @@
import { useEffect } from 'react'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useCatalogStore } from '../../state/catalogStore'
import { useProjectStore } from '../../state/projectStore'
import { useSubmissionStore } from '../../state/submissionStore'
@@ -14,12 +16,23 @@ export default function AppShell() {
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions)
const loadAuth = useAuthStore((s) => s.load)
const role = useAuthStore((s) => s.role)
const loadAdminQueue = useAdminReviewStore((s) => s.loadAll)
useEffect(() => {
loadInitialDiagram()
loadCatalog()
loadMySubmissions()
}, [loadInitialDiagram, loadCatalog, loadMySubmissions])
loadAuth()
}, [loadInitialDiagram, loadCatalog, loadMySubmissions, loadAuth])
// Only Admins/Super-Admins need the review queue at all — and role isn't
// known until loadAuth() above resolves, so this is a separate effect
// keyed on it rather than bundled into the one above.
useEffect(() => {
if (role === 'admin' || role === 'super_admin') loadAdminQueue()
}, [role, loadAdminQueue])
if (!isLoaded || !isCatalogLoaded) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
+64 -22
View File
@@ -1,39 +1,61 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { supabase } from '../../data/supabaseClient'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useAuthStore } from '../../state/authStore'
import { useProjectStore } from '../../state/projectStore'
import { useSubmissionStore } from '../../state/submissionStore'
import AdminReviewModal from '../submissions/AdminReviewModal'
import MySubmissionsModal from '../submissions/MySubmissionsModal'
import DiagramManagerModal from './DiagramManagerModal'
// When "My Submissions" was last opened — a per-user UI preference (like
// hiddenPublicDeviceTemplateIds in catalogStore), used to badge outcomes
// (approved/rejected) you haven't seen yet without a server-side
// notifications table.
const LAST_SEEN_SUBMISSIONS_KEY = 'av-planner:lastSeenSubmissionsAt'
function getLastSeenSubmissionsAt(): string {
try {
return window.localStorage.getItem(LAST_SEEN_SUBMISSIONS_KEY) ?? ''
} catch {
return ''
}
}
function setLastSeenSubmissionsAt(iso: string): void {
try {
window.localStorage.setItem(LAST_SEEN_SUBMISSIONS_KEY, iso)
} catch {
// Not essential — worst case an already-seen outcome stays badged.
}
}
export default function TopBar() {
const project = useProjectStore((s) => s.project)
const renameProject = useProjectStore((s) => s.renameProject)
const importProject = useProjectStore((s) => s.importProject)
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
const username = useAuthStore((s) => s.username)
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
const fileInputRef = useRef<HTMLInputElement>(null)
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
const [submissionsOpen, setSubmissionsOpen] = useState(false)
const [username, setUsername] = useState<string | null>(null)
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
const pendingSubmissionCount = useMemo(
() => mySubmissions.filter((s) => s.status === 'pending').length,
[mySubmissions],
)
const unseenOutcomeCount = useMemo(() => {
const lastSeen = getLastSeenSubmissionsAt()
return mySubmissions.filter((s) => s.status !== 'pending' && s.updatedAt > lastSeen).length
}, [mySubmissions])
// One-time fetch — nothing else in the app changes your own username after
// CompleteProfileScreen sets it, so there's no need to keep this live.
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) return
supabase
.from('profiles')
.select('username')
.eq('id', user.id)
.single()
.then(({ data }) => setUsername(data?.username ?? null))
})
}, [])
const openMySubmissions = () => {
setSubmissionsOpen(true)
setLastSeenSubmissionsAt(new Date().toISOString())
}
const handleExport = () => downloadProjectFile(project)
@@ -69,17 +91,36 @@ export default function TopBar() {
>
Diagrams
</button>
<button
onClick={() => setSubmissionsOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
My Submissions
{pendingSubmissionCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{pendingSubmissionCount}
{unseenOutcomeCount > 0 ? (
<span
className="ml-1 rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700"
title="Submission(s) reviewed since you last checked"
>
{unseenOutcomeCount}
</span>
) : (
pendingSubmissionCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{pendingSubmissionCount}
</span>
)
)}
</button>
{isAdmin && (
<button
onClick={() => setAdminReviewOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Review Queue
{adminPendingCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)}
</button>
)}
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Import
</button>
@@ -100,6 +141,7 @@ export default function TopBar() {
</div>
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
</header>
)
}
@@ -0,0 +1,250 @@
import { useEffect, useMemo, useState } from 'react'
import type { UsageImpact } from '../../data/AdminSubmissionRepository'
import type { CatalogSubmission } from '../../data/SubmissionRepository'
import type { Catalog } from '../../domain/types'
import { useAdminReviewStore } from '../../state/adminReviewStore'
import { useCatalogStore } from '../../state/catalogStore'
import Modal from '../common/Modal'
import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup'
import { findLikelyDuplicates } from './duplicateDetection'
import { diffRows, fieldLabel, formatDiffValue } from './submissionDiff'
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
device_template: 'Device',
port_type: 'Port type',
cable_type: 'Cable type',
device_category: 'Category',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function SubmissionReviewCard({
submission,
catalog,
submitterUsername,
}: {
submission: CatalogSubmission
catalog: Catalog
submitterUsername: string | undefined
}) {
const approve = useAdminReviewStore((s) => s.approve)
const reject = useAdminReviewStore((s) => s.reject)
const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact)
const [rejecting, setRejecting] = useState(false)
const [reason, setReason] = useState('')
const [busy, setBusy] = useState(false)
const [impact, setImpact] = useState<UsageImpact | null>(null)
const [impactLoading, setImpactLoading] = useState(false)
const currentEntity = findCatalogEntity(catalog, submission.entityType, submission.entityId)
// Already public: this is a "suggest an edit" submission, so the impact
// check applies (organized-ideas.md §3) — an edit to an entry already in
// use elsewhere is the case that check exists for. A submission whose
// entity is still private is a "promote my own entry" submission — only
// the submitter's own diagrams could reference it, nothing to warn about.
const isEditToPublicEntry = currentEntity?.custom === false
useEffect(() => {
if (!isEditToPublicEntry) return
setImpactLoading(true)
getUsageImpact(submission.entityType, submission.entityId)
.then(setImpact)
.finally(() => setImpactLoading(false))
// Only ever needs to run once per card mount — the entity/submission
// this card is about doesn't change out from under it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEditToPublicEntry])
const diffs = useMemo(() => {
const currentRow = currentEntity ? catalogEntityToRow(submission.entityType, currentEntity) : {}
return diffRows(currentRow, submission.proposedData)
}, [currentEntity, submission.entityType, submission.proposedData])
const duplicates = useMemo(() => {
if (isEditToPublicEntry || submission.entityType !== 'device_template') return []
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : ''
if (!proposedName) return []
const publicTemplates = catalog.deviceTemplates
.filter((dt) => !dt.custom && dt.id !== submission.entityId)
.map((dt) => ({ id: dt.id, name: dt.name }))
return findLikelyDuplicates(proposedName, publicTemplates)
}, [isEditToPublicEntry, submission.entityType, submission.entityId, submission.proposedData, catalog.deviceTemplates])
const handleApprove = async () => {
setBusy(true)
try {
await approve(submission)
} finally {
setBusy(false)
}
}
const handleReject = async () => {
if (!reason.trim()) return
setBusy(true)
try {
await reject(submission.id, reason.trim())
} finally {
setBusy(false)
setRejecting(false)
setReason('')
}
}
const displayName = currentEntity?.name ?? (typeof submission.proposedData.name === 'string' ? submission.proposedData.name : submission.entityId)
return (
<div className="rounded border border-slate-200 bg-white p-3 text-xs">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-700">{displayName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500">
{ENTITY_TYPE_LABELS[submission.entityType]}
</span>
{!isEditToPublicEntry && (
<span className="rounded bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-600">
New public entry
</span>
)}
</div>
<div className="mt-0.5 text-[10px] text-slate-400">
{submitterUsername ?? 'Unknown user'} · submitted {formatDate(submission.createdAt)}
</div>
</div>
</div>
{!currentEntity && (
<p className="mt-2 rounded bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
The underlying entry no longer exists — this submission can only be rejected.
</p>
)}
{diffs.length > 0 && (
<div className="mt-2 space-y-1 rounded border border-slate-100 bg-slate-50 p-2">
{diffs.map((d) => (
<div key={d.key} className="flex flex-wrap items-baseline gap-x-1.5 text-[11px]">
<span className="font-medium text-slate-500">{fieldLabel(d.key)}:</span>
<span className="text-slate-400 line-through">{formatDiffValue(d.key, d.before)}</span>
<span className="text-slate-400">→</span>
<span className="font-medium text-slate-700">{formatDiffValue(d.key, d.after)}</span>
</div>
))}
</div>
)}
{diffs.length === 0 && currentEntity && (
<p className="mt-2 text-[11px] italic text-slate-400">No changes proposed beyond publishing this as-is.</p>
)}
{duplicates.length > 0 && (
<p className="mt-2 rounded bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
<span className="font-medium">Possible duplicate of: </span>
{duplicates.map((d) => d.name).join(', ')}
</p>
)}
{isEditToPublicEntry && (
<div className="mt-2 rounded border border-slate-100 bg-slate-50 px-2 py-1.5 text-[11px]">
{impactLoading ? (
<span className="text-slate-400">Checking how many diagrams use this…</span>
) : impact ? (
impact.diagramCount === 0 ? (
<span className="text-slate-500">Not currently used in any diagram.</span>
) : (
<div>
<span className="font-medium text-amber-700">
Used in {impact.diagramCount} diagram{impact.diagramCount === 1 ? '' : 's'}:
</span>{' '}
<span className="text-slate-600">
{impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}
{impact.diagramCount > impact.sample.length ? ', …' : ''}
</span>
</div>
)
) : null}
</div>
)}
{rejecting ? (
<div className="mt-2 space-y-1.5">
<textarea
autoFocus
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (shown to the submitter, and required)"
rows={2}
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs"
/>
<div className="flex justify-end gap-2">
<button onClick={() => setRejecting(false)} className="text-[11px] text-slate-500">
Cancel
</button>
<button
onClick={handleReject}
disabled={busy || !reason.trim()}
className="rounded bg-red-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Confirm reject
</button>
</div>
</div>
) : (
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => setRejecting(true)}
disabled={busy}
className="rounded bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Reject
</button>
<button
onClick={handleApprove}
disabled={busy || !currentEntity}
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Approve
</button>
</div>
)}
</div>
)
}
export default function AdminReviewModal({ onClose }: { onClose: () => void }) {
const catalog = useCatalogStore((s) => s.catalog)
const allSubmissions = useAdminReviewStore((s) => s.allSubmissions)
const submitterUsernames = useAdminReviewStore((s) => s.submitterUsernames)
// Oldest first — a review queue should drain FIFO, not bury older
// submissions under newer ones.
const pending = useMemo(
() =>
allSubmissions
.filter((s) => s.status === 'pending')
.sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[allSubmissions],
)
return (
<Modal title="Review Queue" onClose={onClose} width="max-w-2xl">
<div className="space-y-2">
{pending.length === 0 && <p className="py-2 text-center text-xs italic text-slate-400">Nothing pending review.</p>}
{pending.map((submission) => (
<SubmissionReviewCard
key={submission.id}
submission={submission}
catalog={catalog}
submitterUsername={submitterUsernames[submission.id]}
/>
))}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -1,13 +1,5 @@
import { useState } from 'react'
import {
cableTypeToRow,
deviceCategoryToRow,
deviceTemplateToRow,
portTypeToRow,
rowToCableTypeFields,
rowToDeviceTemplateFields,
rowToPortTypeFields,
} from '../../data/catalogRowMapping'
import { cableTypeToRow, portTypeToRow, rowToCableTypeFields, rowToDeviceTemplateFields, rowToPortTypeFields } from '../../data/catalogRowMapping'
import type { CatalogSubmission } from '../../data/SubmissionRepository'
import { allPortTypes } from '../../domain/project'
import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
@@ -17,6 +9,7 @@ import { resolveCableTypeFields } from '../palette/cableTypeFormFields'
import { CableTypeForm } from '../palette/CableTypeManager'
import DeviceTemplateEditor from '../palette/DeviceTemplateEditor'
import { PortTypeForm } from '../palette/PortTypeManager'
import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup'
import Modal from '../common/Modal'
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
@@ -26,28 +19,13 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
device_category: 'Category',
}
/** The submission's entity, as it currently exists in the catalog (public
* or private), if it still exists at all. */
function findEntity(catalog: Catalog, submission: CatalogSubmission) {
switch (submission.entityType) {
case 'port_type':
return catalog.portTypes.find((pt) => pt.id === submission.entityId)
case 'cable_type':
return catalog.cableTypes.find((ct) => ct.id === submission.entityId)
case 'device_template':
return catalog.deviceTemplates.find((dt) => dt.id === submission.entityId)
case 'device_category':
return catalog.deviceCategories.find((c) => c.id === submission.entityId)
}
}
/** Best-effort display name: the entity's current live name if it still
* exists in the catalog, else whatever name was proposed (it always will
* exist in this app's flow — entity_id always points at a real row — but
* falling back is cheap insurance). */
function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): string {
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : undefined
return findEntity(catalog, submission)?.name ?? proposedName ?? submission.entityId
return findCatalogEntity(catalog, submission.entityType, submission.entityId)?.name ?? proposedName ?? submission.entityId
}
/** True when this submission is about an entry you still privately own —
@@ -57,25 +35,15 @@ function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): str
* keeps proposed_data synced to it automatically (see syncProposedData),
* so there's no separate draft to edit here. */
function isOwnPrivateEntry(catalog: Catalog, submission: CatalogSubmission): boolean {
return findEntity(catalog, submission)?.custom === true
return findCatalogEntity(catalog, submission.entityType, submission.entityId)?.custom === true
}
/** Row-shaped snapshot of the live entity, for the "Resubmit" action on an
* own-private-entry submission — re-confirms the (already-synced)
* proposed_data and flips status back to pending in one explicit click. */
function liveEntityRow(catalog: Catalog, submission: CatalogSubmission): Record<string, unknown> | undefined {
const entity = findEntity(catalog, submission)
if (!entity) return undefined
switch (submission.entityType) {
case 'port_type':
return portTypeToRow(entity as PortType)
case 'cable_type':
return cableTypeToRow(entity as CableType)
case 'device_template':
return deviceTemplateToRow(entity as DeviceTemplate)
case 'device_category':
return deviceCategoryToRow(entity.name)
}
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId)
return entity ? catalogEntityToRow(submission.entityType, entity) : undefined
}
function statusBadge(submission: CatalogSubmission) {
@@ -106,7 +74,8 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
const allPortTypesList = allPortTypes(catalog)
const handleWithdraw = (submission: CatalogSubmission) => {
const confirmed = window.confirm(`Withdraw this submission for "${entityDisplayName(catalog, submission)}"?`)
const verb = submission.status === 'rejected' ? 'Dismiss' : 'Withdraw'
const confirmed = window.confirm(`${verb} this submission for "${entityDisplayName(catalog, submission)}"?`)
if (confirmed) withdraw(submission.id)
}
@@ -139,7 +108,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
// sync afterward (see syncProposedData), so nothing extra is
// needed here beyond editing the live entry itself.
if (isEditing && ownEntry && submission.entityType === 'port_type') {
const entity = findEntity(catalog, submission) as PortType
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as PortType
return (
<PortTypeForm
key={submission.id}
@@ -154,7 +123,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
)
}
if (isEditing && ownEntry && submission.entityType === 'cable_type') {
const entity = findEntity(catalog, submission) as CableType
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as CableType
return (
<CableTypeForm
key={submission.id}
@@ -171,7 +140,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
)
}
if (isEditing && ownEntry && submission.entityType === 'device_template') {
const entity = findEntity(catalog, submission) as DeviceTemplate
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId) as DeviceTemplate
return <DeviceTemplateEditor key={submission.id} template={entity} onClose={() => setEditingId(null)} />
}
@@ -286,12 +255,12 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
{submission.status === 'rejected' ? 'Revise & resubmit' : 'Edit'}
</button>
)}
{submission.status === 'pending' && (
{(submission.status === 'pending' || submission.status === 'rejected') && (
<button
onClick={() => handleWithdraw(submission)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Withdraw
{submission.status === 'rejected' ? 'Dismiss' : 'Withdraw'}
</button>
)}
</div>
@@ -0,0 +1,38 @@
import { cableTypeToRow, deviceCategoryToRow, deviceTemplateToRow, portTypeToRow } from '../../data/catalogRowMapping'
import type { CatalogEntityType } from '../../data/SubmissionRepository'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, PortType } from '../../domain/types'
export type CatalogEntity = PortType | CableType | DeviceTemplate | DeviceCategoryDef
/** Looks up a submission's entity in the current catalog (public or your
* own private) by (entityType, entityId) — shared by MySubmissionsModal
* and AdminReviewModal so "what does this submission reference right now"
* is answered the same way everywhere. */
export function findCatalogEntity(catalog: Catalog, entityType: CatalogEntityType, entityId: string): CatalogEntity | undefined {
switch (entityType) {
case 'port_type':
return catalog.portTypes.find((pt) => pt.id === entityId)
case 'cable_type':
return catalog.cableTypes.find((ct) => ct.id === entityId)
case 'device_template':
return catalog.deviceTemplates.find((dt) => dt.id === entityId)
case 'device_category':
return catalog.deviceCategories.find((c) => c.id === entityId)
}
}
/** Row-shaped snapshot of a live catalog entity (see data/catalogRowMapping.ts)
* — the same shape a submission's `proposed_data` is stored in, so the two
* can be diffed or interchanged directly. */
export function catalogEntityToRow(entityType: CatalogEntityType, entity: CatalogEntity): Record<string, unknown> {
switch (entityType) {
case 'port_type':
return portTypeToRow(entity as PortType)
case 'cable_type':
return cableTypeToRow(entity as CableType)
case 'device_template':
return deviceTemplateToRow(entity as DeviceTemplate)
case 'device_category':
return deviceCategoryToRow(entity.name)
}
}
@@ -0,0 +1,44 @@
/** A light duplicate-detection nudge for Admins reviewing a new device
* submission (organized-ideas.md §3) — fuzzy-matches the proposed name
* against existing public entries so the catalog doesn't slowly fill with
* near-identical entries. Deliberately simple (no server round trip, no
* new Postgres extension): normalize + Levenshtein distance against
* already-loaded public names, which is plenty for a "possible duplicate,
* take a look" nudge rather than a hard duplicate check. */
function normalize(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
}
function levenshteinDistance(a: string, b: string): number {
const rows = a.length + 1
const cols = b.length + 1
const dp: number[][] = Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
for (let i = 0; i < rows; i++) dp[i][0] = i
for (let j = 0; j < cols; j++) dp[0][j] = j
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
return dp[rows - 1][cols - 1]
}
export interface DuplicateCandidate {
id: string
name: string
}
/** Candidates within a generous edit-distance threshold of `name` — a near-
* rename or typo, not just anything vaguely similar. */
export function findLikelyDuplicates(name: string, candidates: DuplicateCandidate[], maxResults = 3): DuplicateCandidate[] {
const target = normalize(name)
if (!target) return []
const threshold = Math.max(2, Math.floor(target.length * 0.3))
return candidates
.map((c) => ({ candidate: c, distance: levenshteinDistance(target, normalize(c.name)) }))
.filter(({ distance }) => distance <= threshold)
.sort((a, b) => a.distance - b.distance)
.slice(0, maxResults)
.map(({ candidate }) => candidate)
}
@@ -0,0 +1,57 @@
/** One changed field between a submission's current live row and its
* proposed_data — used to render the Admin review queue's diff view. */
export interface FieldDiff {
key: string
before: unknown
after: unknown
}
/** Field-by-field diff between two row-shaped objects (see
* data/catalogRowMapping.ts) — only fields that actually differ are
* returned, so a submission that only changes one thing shows one row. */
export function diffRows(current: Record<string, unknown>, proposed: Record<string, unknown>): FieldDiff[] {
const keys = new Set([...Object.keys(current), ...Object.keys(proposed)])
const diffs: FieldDiff[] = []
for (const key of keys) {
const before = current[key]
const after = proposed[key]
if (JSON.stringify(before) !== JSON.stringify(after)) diffs.push({ key, before, after })
}
return diffs
}
const FIELD_LABELS: Record<string, string> = {
name: 'Name',
category: 'Category',
category_id: 'Category',
family: 'Family',
family2: 'Family (end 2)',
compatible_family_ids: 'Also compatible with',
max_connections: 'Max connections',
unit: 'Unit',
cost_per_unit: 'Cost per unit',
manufacturer: 'Manufacturer',
model: 'Model',
cost: 'Cost',
ports: 'Ports',
}
export function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? key
}
/** Formats a diffed field's value for display — arrays and port lists get a
* readable summary rather than raw JSON. */
export function formatDiffValue(key: string, value: unknown): string {
if (value === null || value === undefined) return '—'
if (key === 'ports' && Array.isArray(value)) {
return value
.map((p) => {
const port = p as { name?: string; direction?: string; port_type_id?: string }
return `${port.name ?? '?'} (${port.direction ?? '?'}, ${port.port_type_id ?? '?'})`
})
.join('; ')
}
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—'
return String(value)
}
+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,
}
}
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand'
import type { AdminSubmissionRepository, UsageImpact } from '../data/AdminSubmissionRepository'
import type { CatalogSubmission } from '../data/SubmissionRepository'
import { SupabaseAdminSubmissionRepository } from '../data/SupabaseAdminSubmissionRepository'
import { useCatalogStore } from './catalogStore'
const repository: AdminSubmissionRepository = new SupabaseAdminSubmissionRepository()
interface AdminReviewStoreState {
/** Every submission across all users — RLS only returns this in full to
* an Admin/Super-Admin; a regular user calling this store would just get
* their own submissions back (same as submissionStore). */
allSubmissions: CatalogSubmission[]
/** Submitter username per submission id — see AdminSubmissionRepository.getSubmitterUsernames. */
submitterUsernames: Record<string, string>
isLoaded: boolean
loadAll: () => Promise<void>
/** Approves in place (same id, flips private -> public) and refreshes
* the shared catalog so every open view picks up the change. */
approve: (submission: CatalogSubmission) => Promise<void>
reject: (id: string, reason: string) => Promise<void>
getUsageImpact: (entityType: CatalogSubmission['entityType'], entityId: string) => Promise<UsageImpact>
}
export const useAdminReviewStore = create<AdminReviewStoreState>((set) => ({
allSubmissions: [],
submitterUsernames: {},
isLoaded: false,
loadAll: async () => {
const allSubmissions = await repository.listAll()
const submitterUsernames = await repository.getSubmitterUsernames(allSubmissions.map((s) => s.id))
set({ allSubmissions, submitterUsernames, isLoaded: true })
},
approve: async (submission) => {
await repository.approve(submission)
set((state) => ({
allSubmissions: state.allSubmissions.map((s) =>
s.id === submission.id ? { ...s, status: 'approved' as const, reviewReason: undefined } : s,
),
}))
await useCatalogStore.getState().loadCatalog()
},
reject: async (id, reason) => {
await repository.reject(id, reason)
set((state) => ({
allSubmissions: state.allSubmissions.map((s) =>
s.id === id ? { ...s, status: 'rejected' as const, reviewReason: reason } : s,
),
}))
},
getUsageImpact: (entityType, entityId) => repository.getUsageImpact(entityType, entityId),
}))
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand'
import { supabase } from '../data/supabaseClient'
export type UserRole = 'regular' | 'admin' | 'super_admin'
interface AuthStoreState {
userId: string | null
username: string | null
role: UserRole | null
isLoaded: boolean
/** One-time fetch of your own profile — nothing else in the app changes
* your own username or role while you're signed in (role changes are an
* Admin/Super-Admin-only action on someone else's account, see
* organized-ideas.md §6, not yet built), so there's no need to keep this live. */
load: () => Promise<void>
}
export const useAuthStore = create<AuthStoreState>((set) => ({
userId: null,
username: null,
role: null,
isLoaded: false,
load: async () => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
set({ isLoaded: true })
return
}
const { data } = await supabase.from('profiles').select('username, role').eq('id', user.id).single()
set({
userId: user.id,
username: data?.username ?? null,
role: (data?.role as UserRole | undefined) ?? null,
isLoaded: true,
})
},
}))