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