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 { useEffect } from 'react'
|
||||||
|
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||||
|
import { useAuthStore } from '../../state/authStore'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
@@ -14,12 +16,23 @@ export default function AppShell() {
|
|||||||
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
const isCatalogLoaded = useCatalogStore((s) => s.isLoaded)
|
||||||
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
const loadCatalog = useCatalogStore((s) => s.loadCatalog)
|
||||||
const loadMySubmissions = useSubmissionStore((s) => s.loadMySubmissions)
|
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(() => {
|
useEffect(() => {
|
||||||
loadInitialDiagram()
|
loadInitialDiagram()
|
||||||
loadCatalog()
|
loadCatalog()
|
||||||
loadMySubmissions()
|
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) {
|
if (!isLoaded || !isCatalogLoaded) {
|
||||||
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
|
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 { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
||||||
import { supabase } from '../../data/supabaseClient'
|
import { supabase } from '../../data/supabaseClient'
|
||||||
|
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||||
|
import { useAuthStore } from '../../state/authStore'
|
||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import AdminReviewModal from '../submissions/AdminReviewModal'
|
||||||
import MySubmissionsModal from '../submissions/MySubmissionsModal'
|
import MySubmissionsModal from '../submissions/MySubmissionsModal'
|
||||||
import DiagramManagerModal from './DiagramManagerModal'
|
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() {
|
export default function TopBar() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const renameProject = useProjectStore((s) => s.renameProject)
|
const renameProject = useProjectStore((s) => s.renameProject)
|
||||||
const importProject = useProjectStore((s) => s.importProject)
|
const importProject = useProjectStore((s) => s.importProject)
|
||||||
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
||||||
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
||||||
const [username, setUsername] = useState<string | null>(null)
|
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
|
||||||
|
|
||||||
const pendingSubmissionCount = useMemo(
|
const pendingSubmissionCount = useMemo(
|
||||||
() => mySubmissions.filter((s) => s.status === 'pending').length,
|
() => mySubmissions.filter((s) => s.status === 'pending').length,
|
||||||
[mySubmissions],
|
[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
|
const openMySubmissions = () => {
|
||||||
// CompleteProfileScreen sets it, so there's no need to keep this live.
|
setSubmissionsOpen(true)
|
||||||
useEffect(() => {
|
setLastSeenSubmissionsAt(new Date().toISOString())
|
||||||
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 handleExport = () => downloadProjectFile(project)
|
const handleExport = () => downloadProjectFile(project)
|
||||||
|
|
||||||
@@ -69,17 +91,36 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
Diagrams
|
Diagrams
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
onClick={() => setSubmissionsOpen(true)}
|
|
||||||
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
My Submissions
|
My Submissions
|
||||||
{pendingSubmissionCount > 0 && (
|
{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">
|
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
|
||||||
{pendingSubmissionCount}
|
{pendingSubmissionCount}
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</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">
|
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -100,6 +141,7 @@ export default function TopBar() {
|
|||||||
</div>
|
</div>
|
||||||
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
||||||
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
||||||
|
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
|
||||||
</header>
|
</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 { useState } from 'react'
|
||||||
import {
|
import { cableTypeToRow, portTypeToRow, rowToCableTypeFields, rowToDeviceTemplateFields, rowToPortTypeFields } from '../../data/catalogRowMapping'
|
||||||
cableTypeToRow,
|
|
||||||
deviceCategoryToRow,
|
|
||||||
deviceTemplateToRow,
|
|
||||||
portTypeToRow,
|
|
||||||
rowToCableTypeFields,
|
|
||||||
rowToDeviceTemplateFields,
|
|
||||||
rowToPortTypeFields,
|
|
||||||
} from '../../data/catalogRowMapping'
|
|
||||||
import type { CatalogSubmission } from '../../data/SubmissionRepository'
|
import type { CatalogSubmission } from '../../data/SubmissionRepository'
|
||||||
import { allPortTypes } from '../../domain/project'
|
import { allPortTypes } from '../../domain/project'
|
||||||
import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
|
import type { CableType, Catalog, DeviceTemplate, PortType } from '../../domain/types'
|
||||||
@@ -17,6 +9,7 @@ import { resolveCableTypeFields } from '../palette/cableTypeFormFields'
|
|||||||
import { CableTypeForm } from '../palette/CableTypeManager'
|
import { CableTypeForm } from '../palette/CableTypeManager'
|
||||||
import DeviceTemplateEditor from '../palette/DeviceTemplateEditor'
|
import DeviceTemplateEditor from '../palette/DeviceTemplateEditor'
|
||||||
import { PortTypeForm } from '../palette/PortTypeManager'
|
import { PortTypeForm } from '../palette/PortTypeManager'
|
||||||
|
import { catalogEntityToRow, findCatalogEntity } from './catalogEntityLookup'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
|
const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
|
||||||
@@ -26,28 +19,13 @@ const ENTITY_TYPE_LABELS: Record<CatalogSubmission['entityType'], string> = {
|
|||||||
device_category: 'Category',
|
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
|
/** 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
|
* 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
|
* exist in this app's flow — entity_id always points at a real row — but
|
||||||
* falling back is cheap insurance). */
|
* falling back is cheap insurance). */
|
||||||
function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): string {
|
function entityDisplayName(catalog: Catalog, submission: CatalogSubmission): string {
|
||||||
const proposedName = typeof submission.proposedData.name === 'string' ? submission.proposedData.name : undefined
|
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 —
|
/** 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),
|
* keeps proposed_data synced to it automatically (see syncProposedData),
|
||||||
* so there's no separate draft to edit here. */
|
* so there's no separate draft to edit here. */
|
||||||
function isOwnPrivateEntry(catalog: Catalog, submission: CatalogSubmission): boolean {
|
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
|
/** Row-shaped snapshot of the live entity, for the "Resubmit" action on an
|
||||||
* own-private-entry submission — re-confirms the (already-synced)
|
* own-private-entry submission — re-confirms the (already-synced)
|
||||||
* proposed_data and flips status back to pending in one explicit click. */
|
* proposed_data and flips status back to pending in one explicit click. */
|
||||||
function liveEntityRow(catalog: Catalog, submission: CatalogSubmission): Record<string, unknown> | undefined {
|
function liveEntityRow(catalog: Catalog, submission: CatalogSubmission): Record<string, unknown> | undefined {
|
||||||
const entity = findEntity(catalog, submission)
|
const entity = findCatalogEntity(catalog, submission.entityType, submission.entityId)
|
||||||
if (!entity) return undefined
|
return entity ? catalogEntityToRow(submission.entityType, entity) : 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(submission: CatalogSubmission) {
|
function statusBadge(submission: CatalogSubmission) {
|
||||||
@@ -106,7 +74,8 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
|
|||||||
const allPortTypesList = allPortTypes(catalog)
|
const allPortTypesList = allPortTypes(catalog)
|
||||||
|
|
||||||
const handleWithdraw = (submission: CatalogSubmission) => {
|
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)
|
if (confirmed) withdraw(submission.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +108,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
|
|||||||
// sync afterward (see syncProposedData), so nothing extra is
|
// sync afterward (see syncProposedData), so nothing extra is
|
||||||
// needed here beyond editing the live entry itself.
|
// needed here beyond editing the live entry itself.
|
||||||
if (isEditing && ownEntry && submission.entityType === 'port_type') {
|
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 (
|
return (
|
||||||
<PortTypeForm
|
<PortTypeForm
|
||||||
key={submission.id}
|
key={submission.id}
|
||||||
@@ -154,7 +123,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isEditing && ownEntry && submission.entityType === 'cable_type') {
|
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 (
|
return (
|
||||||
<CableTypeForm
|
<CableTypeForm
|
||||||
key={submission.id}
|
key={submission.id}
|
||||||
@@ -171,7 +140,7 @@ export default function MySubmissionsModal({ onClose }: { onClose: () => void })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isEditing && ownEntry && submission.entityType === 'device_template') {
|
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)} />
|
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'}
|
{submission.status === 'rejected' ? 'Revise & resubmit' : 'Edit'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{submission.status === 'pending' && (
|
{(submission.status === 'pending' || submission.status === 'rejected') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleWithdraw(submission)}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from './SubmissionRepository'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
import { toSubmission, type SubmissionRow } from './submissionRowMapping'
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Backs the app with the `catalog_submissions` table. RLS already scopes
|
/** 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
|
* 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
-- Admin review queue support, per organized-ideas.md §3:
|
||||||
|
-- * a soft per-user cap on pending submissions (abuse prevention, §2)
|
||||||
|
-- * an impact-check an Admin can run before approving an edit to an
|
||||||
|
-- already-public entry — "how many diagrams reference this, and a short
|
||||||
|
-- sample" — computed by a privileged, aggregate-only function so regular
|
||||||
|
-- Admins (who don't have diagram visibility, only Super Admins do, per
|
||||||
|
-- §6) never see raw diagram content, just the blast-radius numbers.
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Per-user pending-submission cap (10 — organized-ideas.md §3's "exact
|
||||||
|
-- number TBD when this is built").
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
drop policy "catalog_submissions_insert" on public.catalog_submissions;
|
||||||
|
|
||||||
|
create policy "catalog_submissions_insert" on public.catalog_submissions for insert
|
||||||
|
with check (
|
||||||
|
submitter_id = auth.uid()
|
||||||
|
and status = 'pending'
|
||||||
|
and (
|
||||||
|
select count(*) from public.catalog_submissions
|
||||||
|
where submitter_id = auth.uid() and status = 'pending'
|
||||||
|
) < 10
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Usage-impact aggregate function.
|
||||||
|
--
|
||||||
|
-- A diagram's `data` JSONB mirrors the exported Project shape (see
|
||||||
|
-- data/exportImport.ts / domain/types.ts): devices[].templateId,
|
||||||
|
-- devices[].category, devices[].ports[].portTypeId, and
|
||||||
|
-- connections[].cableTypeId are the four places a catalog entity id can be
|
||||||
|
-- referenced. security definer so it can read every diagram regardless of
|
||||||
|
-- the caller's own diagrams RLS visibility — the is_admin() check below is
|
||||||
|
-- what keeps this from being an open door, and the return shape (a count
|
||||||
|
-- plus up to 5 {id, name, ownerUsername} samples) is deliberately far short
|
||||||
|
-- of full diagram content.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.catalog_entity_usage_impact(p_entity_type text, p_entity_id text)
|
||||||
|
returns table(diagram_count integer, sample jsonb)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.is_admin() then
|
||||||
|
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select count(*)::int, coalesce(jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name, 'ownerUsername', s.owner_username) order by s.rn) filter (where s.rn <= 5), '[]'::jsonb)
|
||||||
|
from (
|
||||||
|
select d.id, d.name, p.username as owner_username,
|
||||||
|
row_number() over (order by d.updated_at desc) as rn
|
||||||
|
from public.diagrams d
|
||||||
|
join public.profiles p on p.id = d.owner_id
|
||||||
|
where case p_entity_type
|
||||||
|
when 'device_template' then exists (
|
||||||
|
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
|
||||||
|
where dev ->> 'templateId' = p_entity_id
|
||||||
|
)
|
||||||
|
when 'device_category' then exists (
|
||||||
|
select 1 from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev
|
||||||
|
where dev ->> 'category' = p_entity_id
|
||||||
|
)
|
||||||
|
when 'port_type' then exists (
|
||||||
|
select 1
|
||||||
|
from jsonb_array_elements(coalesce(d.data -> 'devices', '[]'::jsonb)) dev,
|
||||||
|
jsonb_array_elements(coalesce(dev -> 'ports', '[]'::jsonb)) port
|
||||||
|
where port ->> 'portTypeId' = p_entity_id
|
||||||
|
)
|
||||||
|
when 'cable_type' then exists (
|
||||||
|
select 1 from jsonb_array_elements(coalesce(d.data -> 'connections', '[]'::jsonb)) conn
|
||||||
|
where conn ->> 'cableTypeId' = p_entity_id
|
||||||
|
)
|
||||||
|
else false
|
||||||
|
end
|
||||||
|
) s;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Batched submitter-username lookup for the review queue list.
|
||||||
|
--
|
||||||
|
-- profiles_select_self_or_super_admin deliberately keeps a regular Admin
|
||||||
|
-- from browsing other users' profiles directly — but an Admin reviewing a
|
||||||
|
-- submission already sees its content, so knowing *who* submitted it isn't
|
||||||
|
-- a bigger exposure than the usage-impact function's owner usernames
|
||||||
|
-- above; it's just gated the same way (admin-only, minimal fields, no
|
||||||
|
-- broader profile browsing). Batched (array in, rows out) so listing a
|
||||||
|
-- whole queue costs one round trip, not one per submission.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.catalog_submission_submitters(p_submission_ids uuid[])
|
||||||
|
returns table(submission_id uuid, username text)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not public.is_admin() then
|
||||||
|
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select s.id, p.username
|
||||||
|
from public.catalog_submissions s
|
||||||
|
join public.profiles p on p.id = s.submitter_id
|
||||||
|
where s.id = any(p_submission_ids);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- A rejected submission previously had no way out: the delete policy only
|
||||||
|
-- allowed withdrawing a still-pending one, but the UI (and organized-ideas.md
|
||||||
|
-- §3's "rejected submissions stay editable for resubmission") never intended
|
||||||
|
-- rejected to mean "stuck forever" — a submitter who decides not to pursue a
|
||||||
|
-- rejected submission further should be able to dismiss it, same as pulling
|
||||||
|
-- back a pending one.
|
||||||
|
drop policy "catalog_submissions_delete" on public.catalog_submissions;
|
||||||
|
|
||||||
|
create policy "catalog_submissions_delete" on public.catalog_submissions for delete
|
||||||
|
using (submitter_id = auth.uid() and status in ('pending', 'rejected'));
|
||||||
+149
-3
@@ -25,7 +25,7 @@ begin;
|
|||||||
|
|
||||||
create extension if not exists pgtap with schema extensions;
|
create extension if not exists pgtap with schema extensions;
|
||||||
|
|
||||||
select plan(23);
|
select plan(38);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
-- Fixtures (as postgres — RLS does not apply)
|
-- Fixtures (as postgres — RLS does not apply)
|
||||||
@@ -205,6 +205,26 @@ select lives_ok(
|
|||||||
'bob can submit a new catalog entry for review'
|
'bob can submit a new catalog entry for review'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Fill the rest of bob's pending-submission cap (organized-ideas.md §3's
|
||||||
|
-- soft cap, set to 10) and confirm the 11th is rejected.
|
||||||
|
insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
||||||
|
select 'device_category', jsonb_build_object('name', 'Bob Cap Filler ' || g), '22222222-2222-2222-2222-222222222222'
|
||||||
|
from generate_series(1, 9) g;
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select count(*)::int from public.catalog_submissions
|
||||||
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'),
|
||||||
|
10,
|
||||||
|
'bob has filled his pending-submission cap (10)'
|
||||||
|
);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
||||||
|
values ('device_category', '{"name":"One Too Many"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$,
|
||||||
|
'42501'::char(5), null,
|
||||||
|
'bob cannot exceed the pending-submission cap'
|
||||||
|
);
|
||||||
|
|
||||||
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||||
|
|
||||||
select is(
|
select is(
|
||||||
@@ -215,10 +235,53 @@ select is(
|
|||||||
|
|
||||||
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||||
|
|
||||||
|
-- 10, not 1: includes the 9 cap-filler submissions inserted above.
|
||||||
select is(
|
select is(
|
||||||
(select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'),
|
(select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'),
|
||||||
1,
|
10,
|
||||||
'carol (admin) can see bob''s submission'
|
'carol (admin) can see all of bob''s submissions'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Withdrawing a submission — pending or rejected. A rejected submission
|
||||||
|
-- previously had no way out (only 'pending' was deletable); it should be
|
||||||
|
-- dismissable the same as a pending one, not stuck forever.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ update public.catalog_submissions
|
||||||
|
set status = 'rejected', reviewer_id = '33333333-3333-3333-3333-333333333333', review_reason = 'Needs more detail'
|
||||||
|
where id = (
|
||||||
|
select id from public.catalog_submissions
|
||||||
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
||||||
|
order by created_at limit 1
|
||||||
|
) $$,
|
||||||
|
'carol (admin) can reject one of bob''s pending submissions'
|
||||||
|
);
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ delete from public.catalog_submissions
|
||||||
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected' $$,
|
||||||
|
'bob can withdraw (delete) his rejected submission'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select count(*)::int from public.catalog_submissions
|
||||||
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected'),
|
||||||
|
0,
|
||||||
|
'the rejected submission is gone after withdrawal'
|
||||||
|
);
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ delete from public.catalog_submissions
|
||||||
|
where id = (
|
||||||
|
select id from public.catalog_submissions
|
||||||
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
||||||
|
limit 1
|
||||||
|
) $$,
|
||||||
|
'bob can still withdraw a pending submission (unchanged behavior)'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
@@ -240,6 +303,89 @@ select lives_ok(
|
|||||||
'dave (super admin) can change another user''s role'
|
'dave (super admin) can change another user''s role'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Usage-impact function (organized-ideas.md §3's impact-check-before-editing:
|
||||||
|
-- an Admin can see the blast radius of a catalog edit without being able to
|
||||||
|
-- see the diagrams themselves).
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||||
|
|
||||||
|
select lives_ok(
|
||||||
|
$$ insert into public.diagrams (id, name, owner_id, data)
|
||||||
|
values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$,
|
||||||
|
'alice can insert a diagram referencing test catalog ids for the impact-check test'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- bob, not alice, for this check: alice was promoted to admin by the role-
|
||||||
|
-- escalation test above, so she'd no longer be a useful "regular user" case.
|
||||||
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$,
|
||||||
|
'42501'::char(5), null,
|
||||||
|
'bob (regular user) cannot call the usage-impact function'
|
||||||
|
);
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
||||||
|
1,
|
||||||
|
'carol (admin) sees the correct impact count for a device template, despite having no direct visibility into that diagram'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select sample -> 0 ->> 'ownerUsername' from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
||||||
|
'alice',
|
||||||
|
'the impact sample identifies the diagram by name/owner username, not raw diagram content'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select diagram_count from public.catalog_entity_usage_impact('port_type', 'pt-impact-test-port')),
|
||||||
|
1,
|
||||||
|
'carol (admin) sees the correct impact count for a port type'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select diagram_count from public.catalog_entity_usage_impact('cable_type', 'ct-impact-test-cable')),
|
||||||
|
1,
|
||||||
|
'carol (admin) sees the correct impact count for a cable type'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
|
||||||
|
0,
|
||||||
|
'the impact count is zero for an entity id referenced by nothing'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Batched submitter-username lookup (gated the same way as the impact
|
||||||
|
-- function above — an Admin reviewing a submission can see who submitted
|
||||||
|
-- it, without a general ability to browse other users' profiles).
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ select * from public.catalog_submission_submitters(
|
||||||
|
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
||||||
|
) $$,
|
||||||
|
'42501'::char(5), null,
|
||||||
|
'bob (regular user) cannot look up submitter usernames'
|
||||||
|
);
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select username from public.catalog_submission_submitters(
|
||||||
|
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
||||||
|
)),
|
||||||
|
'bob',
|
||||||
|
'carol (admin) can look up the submitter''s username for a submission she can review'
|
||||||
|
);
|
||||||
|
|
||||||
select * from finish();
|
select * from finish();
|
||||||
|
|
||||||
rollback;
|
rollback;
|
||||||
|
|||||||
Reference in New Issue
Block a user