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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user