diff --git a/.gitignore b/.gitignore index 725fa17..2486917 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ Thumbs.db # Test / coverage coverage/ + +# Local Claude Code scratch (launch configs, session settings) +.claude/ diff --git a/apps/admin/package.json b/apps/admin/package.json index 73ec86b..0a1df7b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,7 +15,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.52.0", + "@civfix/shared": "^0.53.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/apps/admin/public/ds/pin-cleanup.svg b/apps/admin/public/ds/pin-cleanup.svg index f5d4b07..1b519be 100644 --- a/apps/admin/public/ds/pin-cleanup.svg +++ b/apps/admin/public/ds/pin-cleanup.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-encampment.svg b/apps/admin/public/ds/pin-encampment.svg index cb43235..26f059a 100644 --- a/apps/admin/public/ds/pin-encampment.svg +++ b/apps/admin/public/ds/pin-encampment.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-graffiti.svg b/apps/admin/public/ds/pin-graffiti.svg index 708e0d5..3fa1522 100644 --- a/apps/admin/public/ds/pin-graffiti.svg +++ b/apps/admin/public/ds/pin-graffiti.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-hazard.svg b/apps/admin/public/ds/pin-hazard.svg index cd3ff97..166b5f3 100644 --- a/apps/admin/public/ds/pin-hazard.svg +++ b/apps/admin/public/ds/pin-hazard.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-recycling.svg b/apps/admin/public/ds/pin-recycling.svg index 0df37ea..fb70b00 100644 --- a/apps/admin/public/ds/pin-recycling.svg +++ b/apps/admin/public/ds/pin-recycling.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-trash.svg b/apps/admin/public/ds/pin-trash.svg index 2b343a7..6f28799 100644 --- a/apps/admin/public/ds/pin-trash.svg +++ b/apps/admin/public/ds/pin-trash.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/public/ds/pin-water.svg b/apps/admin/public/ds/pin-water.svg index 713ac5e..c77d2bd 100644 --- a/apps/admin/public/ds/pin-water.svg +++ b/apps/admin/public/ds/pin-water.svg @@ -1,5 +1,5 @@ - + diff --git a/apps/admin/src/components/map/live-map.tsx b/apps/admin/src/components/map/live-map.tsx index aa3246f..c74577d 100644 --- a/apps/admin/src/components/map/live-map.tsx +++ b/apps/admin/src/components/map/live-map.tsx @@ -8,6 +8,12 @@ import { Icons } from "@/components/icons" import { useHomeMap } from "@/hooks/use-admin-home" import { useNav } from "@/store/ui-store" import { EVENT_KIND_PIN_KIND } from "@/lib/event-kind" +import { + BUCKET_VIEW, + reportBucketOf, + reportNeedsAttention, + type ReportBucket, +} from "@/lib/report-status" import type { MapPin, MapTint } from "@/components/map/leaflet-map" @@ -16,8 +22,6 @@ const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) loading: () =>
, }) -const WAITING_REPORT_STATUSES = new Set(["submitted", "held", "published"]) - function toMapPin(p: HomeMapPin): MapPin & { refType: HomeMapPin["refType"] refId: string @@ -26,7 +30,7 @@ function toMapPin(p: HomeMapPin): MapPin & { title: string } { const isEvent = p.refType === "event" - const needs = !isEvent && (p.flagged || WAITING_REPORT_STATUSES.has(p.status)) + const needs = !isEvent && reportNeedsAttention(p.status, p.flagged) return { id: `${p.refType}-${p.id}`, refType: p.refType, @@ -46,13 +50,18 @@ function toMapPin(p: HomeMapPin): MapPin & { type ActivePin = ReturnType +const BUCKET_TONE: Record = { + submitted: "var(--ink-2)", + in_progress: "var(--lilac-600)", + completed: "var(--moss-700)", + removed: "var(--bloom-700)", +} + function statusTone(m: ActivePin): { color: string; label: string } { if (m.refType === "event") return { color: "var(--sun-700)", label: "Cleanup event" } if (m.flagged) return { color: "var(--bloom-700)", label: "Flagged" } - if (WAITING_REPORT_STATUSES.has(m.status)) return { color: "var(--ink-2)", label: "Submitted" } - if (m.status === "in_progress" || m.status === "acknowledged") - return { color: "var(--lilac-600)", label: "In progress" } - return { color: "var(--moss-700)", label: "Completed" } + const bucket = reportBucketOf(m.status) + return { color: BUCKET_TONE[bucket], label: BUCKET_VIEW[bucket].label } } export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { @@ -65,7 +74,7 @@ export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { const reportCount = pins.filter((p) => p.refType === "report").length const eventCount = pins.filter((p) => p.refType === "event").length const needsAttention = pins.filter( - (p) => p.refType === "report" && (p.flagged || WAITING_REPORT_STATUSES.has(p.status)), + (p) => p.refType === "report" && reportNeedsAttention(p.status, p.flagged), ).length const openActive = () => { diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx index 9c2acdf..3f6567b 100644 --- a/apps/admin/src/features/discovery/discovery-page.tsx +++ b/apps/admin/src/features/discovery/discovery-page.tsx @@ -14,7 +14,12 @@ import { import { Icons } from "@/components/icons" import { toAppError } from "@/lib/api" -import { categoryLabel, categoryPinSrc } from "@/lib/category" +import { + REPORT_CATEGORIES, + categoryLabel, + categoryPinSrc, + categoryReportTypes, +} from "@/lib/category" import { promptDialog } from "@/components/shared/dialog" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" @@ -89,19 +94,13 @@ function isOverdue(iso: string | null): boolean { return Date.now() - then > 24 * HOUR_MS } -const CATEGORIES: readonly ReportCategory[] = [ - "trash", - "recycling", - "graffiti", - "hazard", - "encampment", - "water", - "other", -] - -const REPORT_TYPES: { id: ReportCategory; label: string; pin: string }[] = CATEGORIES.map( - (id) => ({ id, label: categoryLabel(id), pin: categoryPinSrc(id) }), -) +const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] = + REPORT_CATEGORIES.map((id) => ({ + id, + label: categoryLabel(id), + pin: categoryPinSrc(id), + types: categoryReportTypes(id), + })) function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { return counts[id] ?? 0 @@ -110,7 +109,7 @@ function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { function dominantCategory(counts: PerCategoryCounts): ReportCategory | null { let best: ReportCategory | null = null let bestN = 0 - for (const c of CATEGORIES) { + for (const c of REPORT_CATEGORIES) { const n = counts[c] ?? 0 if (n > bestN) { bestN = n @@ -337,29 +336,31 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { ) } - const onSaveDraft = () => { + const normalizedHandle = handle.trim().replace(/^@+/, "").toLowerCase() + const handleChanged = normalizedHandle !== (dto.handle ?? "") + + const noteAndHandleFields = () => { const note = opNote.trim() + return { + ...(note ? { notes: note } : {}), + ...(handleChanged ? { handle: normalizedHandle } : {}), + } + } + + const onSaveDraft = () => { const contacts = contactsPayload() const jf = jurisdictionFields() - const normalizedHandle = handle.trim().replace(/^@+/, "").toLowerCase() - const handleChanged = normalizedHandle !== (dto.handle ?? "") + const extras = noteAndHandleFields() if ( Object.keys(contacts).length === 0 && Object.keys(jf).length === 0 && - note === "" && - !handleChanged + Object.keys(extras).length === 0 ) { toast("Nothing to save yet") return } patch.mutate( - { - geoid: dto.geoid, - contacts, - ...jf, - ...(note ? { notes: note } : {}), - ...(handleChanged ? { handle: normalizedHandle } : {}), - }, + { geoid: dto.geoid, contacts, ...jf, ...extras }, { onSuccess: () => toast(`Draft saved for ${dto.org}`), onError: (err) => toast(toAppError(err).message), @@ -369,9 +370,25 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { const onSaveContacts = () => { if (!canSave) return - saveContacts.mutate( - { geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() }, - { onSuccess: () => toast(`Contacts saved for ${dto.org}`) }, + const saveAndRoute = () => + saveContacts.mutate( + { geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() }, + { + onSuccess: () => + toast(`Contacts saved for ${dto.org} · discovery task closed`), + }, + ) + const extras = noteAndHandleFields() + if (Object.keys(extras).length === 0) { + saveAndRoute() + return + } + patch.mutate( + { geoid: dto.geoid, ...extras }, + { + onSuccess: saveAndRoute, + onError: (err) => toast(toAppError(err).message), + }, ) } @@ -485,10 +502,9 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { />
- Residents can tag “@ - {handle.trim().replace(/^@+/, "").toLowerCase() || "handle"}” in a report’s discussion to - forward it to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to - clear. Saved with “Save draft”. + Residents can tag “@{normalizedHandle || "handle"}” in a report’s discussion to forward it + to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to clear. + Saved by either “Save draft” or “Save & route”.
@@ -554,6 +570,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { {n} {n === 1 ? "report" : "reports"} + {c.types &&
{c.types}
}
- Counts are reports waiting per type · highlighted types have reports but no contact yet. + Counts are reports waiting per category · the grey line lists the report types neighbors + pick that fold into it · highlighted categories have reports but no contact yet.
@@ -618,20 +636,39 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { - +
+ + + Save & route saves the contacts, the note and the @handle, closes the discovery + task, and queues an outreach digest to this jurisdiction when outreach digests are enabled. It + does not email the reports already waiting — send each of those from its report.{" "} + Save draft saves the same fields and leaves the discovery task open. + +
+ setTemplateOpen(false)} @@ -729,7 +766,7 @@ function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { } export function DiscoveryPage({ focusId }: SectionPageProps) { - const [filter, setFilter] = React.useState("all") + const [filter, setFilter] = React.useState("attention") const [layer, setLayer] = React.useState<"all" | JurisdictionLayer>("all") const [sort, setSort] = React.useState("pop") const [query, setQuery] = React.useState(focusId ?? "") @@ -785,14 +822,14 @@ export function DiscoveryPage({ focusId }: SectionPageProps) { const selected = focused ?? (holdingFocus ? null : (items[0] ?? null)) const catFilters = [ - { value: "all", label: "All", count: allTotal ?? 0 }, - { value: "attention", label: "Need mapping", count: needsMappingCountDisplay }, + { value: "attention", label: "Needs mapping", count: needsMappingCountDisplay }, { value: "clear", label: "Routed", count: facets?.routed ?? 0 }, + { value: "all", label: "All", count: allTotal ?? 0 }, ] const onFilterChange = (v: JurisdictionFilter) => { setFilter(v) - // The "need mapping" view is about the most-overdue reports first, so default it to the + // The "needs mapping" view is about the most-overdue reports first, so default it to the // oldest-first sort; leaving the view falls back to population unless the operator picked reports. if (v === "attention") setSort("oldest") else if (sort === "oldest") setSort("pop") diff --git a/apps/admin/src/features/discovery/discovery-ui-state.test.ts b/apps/admin/src/features/discovery/discovery-ui-state.test.ts index 02cd2e7..8f9e9a8 100644 --- a/apps/admin/src/features/discovery/discovery-ui-state.test.ts +++ b/apps/admin/src/features/discovery/discovery-ui-state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import { getJurisdictionSort, getNeedsMappingCountDisplay } from "./discovery-ui-state" describe("Jurisdictions UI state", () => { - it("always uses oldest sort for the Need mapping filter", () => { + it("always uses oldest sort for the Needs mapping filter", () => { expect(getJurisdictionSort("attention", "pop")).toBe("oldest") expect(getJurisdictionSort("attention", "reports")).toBe("oldest") expect(getJurisdictionSort("attention", "oldest")).toBe("oldest") diff --git a/apps/admin/src/features/discovery/use-discovery.ts b/apps/admin/src/features/discovery/use-discovery.ts index 297f023..a3ba073 100644 --- a/apps/admin/src/features/discovery/use-discovery.ts +++ b/apps/admin/src/features/discovery/use-discovery.ts @@ -21,7 +21,7 @@ import { queryKeys } from "@/lib/query" /** * Data hooks for the Discovery / Jurisdictions section (enumeration 2.B). Reads use GET /admin/discovery * (list) and GET /admin/discovery/:id (detail); writes use the discovery + jurisdictions mutations. All - * mutations invalidate the discovery + jurisdictions caches plus the cross-cutting home + activity feeds + * mutations invalidate the discovery + jurisdictions caches plus the cross-cutting home summary * (a saved contact / flag changes the dashboard aggregates), matching the scaffold's documented pattern. * * Query keys: reuses the existing registry in src/lib/query.ts (discovery.list/detail/all, @@ -89,12 +89,11 @@ export function useJurisdictionGeometry(geoid: string | null) { }) } -/** Invalidate every discovery/jurisdiction view plus the home + activity aggregates after a write. */ +/** Invalidate every discovery/jurisdiction view plus the home aggregates after a write. */ function invalidateDiscovery(qc: ReturnType) { qc.invalidateQueries({ queryKey: queryKeys.discovery.all }) qc.invalidateQueries({ queryKey: queryKeys.jurisdictions.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } /** POST /admin/discovery/:id/notes - append an operator note to a discovery task. */ diff --git a/apps/admin/src/features/events/events-page.tsx b/apps/admin/src/features/events/events-page.tsx index 3c4fcd5..a3a9f21 100644 --- a/apps/admin/src/features/events/events-page.tsx +++ b/apps/admin/src/features/events/events-page.tsx @@ -410,10 +410,14 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: ( ) } - const onUnlink = (report: LinkedReportRef) => { - if (typeof window !== "undefined" && !window.confirm(`Unlink "${report.title}" from this cleanup?`)) { - return - } + const onUnlink = async (report: LinkedReportRef) => { + const ok = await confirmDialog({ + title: "Unlink report", + body: `This unlinks "${report.title}" from this event. The report itself is untouched.`, + danger: true, + confirmLabel: "Unlink", + }) + if (!ok) return unlinkReport.mutate( { id: event.id, reportId: report.id }, { onSuccess: () => toast(`${shortId(event.id)} · report unlinked`) }, @@ -561,7 +565,7 @@ function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: ( key={r.id} report={r} onOpen={() => nav("reports", r.id)} - onUnlink={() => onUnlink(r)} + onUnlink={() => void onUnlink(r)} unlinking={unlinkReport.isPending} /> ))} @@ -803,8 +807,8 @@ export function EventsPage({ focusId }: SectionPageProps) { title="Events" subtitle={ - Community cleanup events neighbors organize on civfix — track turnout, keep them on the - level, and message attendees. + Events neighbors organize on civfix — cleanups and other volunteer events alike. Track + turnout, keep them on the level, and message attendees. } /> diff --git a/apps/admin/src/features/events/use-events.ts b/apps/admin/src/features/events/use-events.ts index 3c1fdf9..08690eb 100644 --- a/apps/admin/src/features/events/use-events.ts +++ b/apps/admin/src/features/events/use-events.ts @@ -48,7 +48,6 @@ function invalidateEvents(qc: ReturnType, id: string) { qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.events.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useFlagEvent() { diff --git a/apps/admin/src/features/home/home-page.tsx b/apps/admin/src/features/home/home-page.tsx index 832d18f..8822498 100644 --- a/apps/admin/src/features/home/home-page.tsx +++ b/apps/admin/src/features/home/home-page.tsx @@ -75,7 +75,8 @@ interface SectionSummary { page: PageId label: string hue: string - lead: number + /** Null when no trustworthy server-side total exists for this section; the tile then leads with `unit`. */ + lead: number | null unit: string blurb?: string stats: SectionStat[] @@ -489,7 +490,13 @@ function SectionTile({ {s.label} {!metric && ( - {s.lead} {s.unit} + {s.lead === null ? ( + s.unit + ) : ( + <> + {s.lead} {s.unit} + + )} )} @@ -580,12 +587,12 @@ export function HomePage(_props: SectionPageProps) { () => (summaryQuery.data ? buildSummaries(summaryQuery.data) : []), [summaryQuery.data], ) - const inboxUnread = (inboxQuery.data?.items ?? []).filter((i) => i.unread).length const mailSummary = React.useMemo(() => { const base = summaries.find((s) => s.id === "mail") if (!base) return undefined const needsAction = summaryQuery.data?.mail.needsAction ?? 0 - const presentation = getMailPreviewPresentation(base.lead, inboxUnread) + const inboxUnread = summaryQuery.data?.inboxUnread + const presentation = getMailPreviewPresentation(base.lead ?? 0) return { ...base, lead: presentation.lead, @@ -595,14 +602,14 @@ export function HomePage(_props: SectionPageProps) { cta: "Open mail", stats: [ { k: "Needs action", v: needsAction, tone: needsAction > 0 ? "warn" : null }, - { k: presentation.loadedInboxLabel, v: presentation.loadedInboxUnread }, + ...(inboxUnread === undefined + ? [] + : [{ k: "Inbox unread", v: inboxUnread, tone: null } satisfies SectionStat]), ], } - }, [summaries, inboxUnread, summaryQuery.data]) + }, [summaries, summaryQuery.data]) const moderationItems = moderationQuery.data?.items ?? [] - const moderationPresentation = getModerationPreviewPresentation( - moderationItems.slice(0, PREVIEW_ROWS).length, - ) + const moderationPresentation = getModerationPreviewPresentation(summaryQuery.data?.moderationQueue) const moderationSummary = React.useMemo( () => ({ id: "moderation", diff --git a/apps/admin/src/features/home/home-preview-presentation.test.ts b/apps/admin/src/features/home/home-preview-presentation.test.ts index cab798e..e83e244 100644 --- a/apps/admin/src/features/home/home-preview-presentation.test.ts +++ b/apps/admin/src/features/home/home-preview-presentation.test.ts @@ -6,20 +6,26 @@ import { } from "./home-preview-presentation" describe("home preview presentation", () => { - it("keeps the reliable outreach unread total separate from loaded catch-all mail", () => { - expect(getMailPreviewPresentation(7, 2)).toEqual({ + it("leads the mail tile with the server-side outreach unread total", () => { + expect(getMailPreviewPresentation(7)).toEqual({ lead: 7, unit: "unread outreach messages", - loadedInboxLabel: "Loaded catch-all unread", - loadedInboxUnread: 2, }) + expect(getMailPreviewPresentation(1).unit).toBe("unread outreach message") }) - it("labels moderation preview length as loaded rather than a queue total", () => { - expect(getModerationPreviewPresentation(2)).toEqual({ - lead: 2, - unit: "loaded queue items", + it("leads the moderation tile with a label, never the preview length", () => { + expect(getModerationPreviewPresentation()).toEqual({ + lead: null, + unit: "user reports, held media, clusters and appeals", }) - expect(getModerationPreviewPresentation(1).unit).toBe("loaded queue item") + }) + + it("leads the moderation tile with the server-side queue total when the summary carries one", () => { + expect(getModerationPreviewPresentation(4)).toEqual({ + lead: 4, + unit: "queued — user reports, held media, clusters and appeals", + }) + expect(getModerationPreviewPresentation(0).lead).toBe(0) }) }) diff --git a/apps/admin/src/features/home/home-preview-presentation.ts b/apps/admin/src/features/home/home-preview-presentation.ts index 67a1d46..e4e7823 100644 --- a/apps/admin/src/features/home/home-preview-presentation.ts +++ b/apps/admin/src/features/home/home-preview-presentation.ts @@ -1,15 +1,18 @@ -export function getMailPreviewPresentation(outreachUnread: number, loadedInboxUnread: number) { +export function getMailPreviewPresentation(outreachUnread: number) { return { lead: outreachUnread, unit: outreachUnread === 1 ? "unread outreach message" : "unread outreach messages", - loadedInboxLabel: "Loaded catch-all unread", - loadedInboxUnread, } } -export function getModerationPreviewPresentation(shown: number) { - return { - lead: shown, - unit: shown === 1 ? "loaded queue item" : "loaded queue items", - } +/** + * The moderation tile leads with the server-side queue total when the summary carries one. When the + * field is absent (an older API) it leads with a plain label instead of the length of the two-row + * preview, which is not a count of anything. + */ +const MODERATION_MIX = "user reports, held media, clusters and appeals" + +export function getModerationPreviewPresentation(queueTotal?: number) { + if (queueTotal === undefined) return { lead: null, unit: MODERATION_MIX } + return { lead: queueTotal, unit: `queued — ${MODERATION_MIX}` } } diff --git a/apps/admin/src/features/inbox/use-inbox.ts b/apps/admin/src/features/inbox/use-inbox.ts index b9d0550..d7c6536 100644 --- a/apps/admin/src/features/inbox/use-inbox.ts +++ b/apps/admin/src/features/inbox/use-inbox.ts @@ -44,7 +44,6 @@ function invalidateInbox(qc: ReturnType, id?: string) { if (id) qc.invalidateQueries({ queryKey: queryKeys.inbox.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.inbox.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useSetInboxStatus() { diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index 8fdbbf9..daf660c 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -13,7 +13,12 @@ import { } from "@civfix/shared" import { Icons } from "@/components/icons" -import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { + PageHead, + FilterChips, + EmptyState, + type FilterOption, +} from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { useComposeMail, @@ -38,8 +43,6 @@ import type { SectionPageProps } from "@/components/shell/page-registry" type Folder = "outreach" | "inbox" -const ATTENTION: MailStatus[] = ["needs_action", "bounced"] - const STATUS_CLS: Record = { replied: "status-ok", delivered: "status-ok", @@ -72,12 +75,6 @@ const DELIVERY_BADGE = { }, } as const -const LOADED_TITLE = "Loaded so far" - -function loadedCount(n: number, hasMore: boolean): string { - return hasMore ? `${n}+` : String(n) -} - function DeliveryBadge({ delivery }: { delivery: MailMessageDTO["delivery"] }) { if (delivery !== "failed" && delivery !== "pending") return null const badge = DELIVERY_BADGE[delivery] @@ -285,11 +282,11 @@ function MailReader({ threadId }: { threadId: string }) { ) } - const markDone = () => { + const markReplied = () => { setStatus.mutate( { id: sel.id, status: "replied" }, { - onSuccess: () => toast("Marked done"), + onSuccess: () => toast("Marked replied"), onError: (err) => toast(errorMessage(err, {}, { fallback: "Couldn't update the thread." })), }, ) @@ -441,8 +438,13 @@ function MailReader({ threadId }: { threadId: string }) { To {who}
-
@@ -742,9 +705,7 @@ export function MailPage({ focusId }: SectionPageProps) {

{BOX_LABEL[box] ?? "All"}

- - {loadedCount(activeCount, !!activeListQuery.hasNextPage)} - + {activeCount}
{activeListQuery.isLoading ? ( diff --git a/apps/admin/src/features/mail/use-mail.ts b/apps/admin/src/features/mail/use-mail.ts index 3f4f820..7c693d8 100644 --- a/apps/admin/src/features/mail/use-mail.ts +++ b/apps/admin/src/features/mail/use-mail.ts @@ -61,7 +61,6 @@ function invalidateMail(qc: ReturnType, id?: string) { qc.invalidateQueries({ queryKey: queryKeys.mail.all }) qc.invalidateQueries({ queryKey: queryKeys.mail.stats }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useComposeMail() { diff --git a/apps/admin/src/features/moderation/gov-claim-presentation.test.ts b/apps/admin/src/features/moderation/gov-claim-presentation.test.ts new file mode 100644 index 0000000..893a521 --- /dev/null +++ b/apps/admin/src/features/moderation/gov-claim-presentation.test.ts @@ -0,0 +1,128 @@ +import { + AppError, + ErrorCode, + GOV_VERIFICATION_CHECK_LABELS, + GovClaimStatusSchema, + type GovClaimDTO, +} from "@civfix/shared" +import { describe, expect, it } from "vitest" + +import { + GOV_CHECKS, + GOV_CLAIM_STATUS_VIEW, + govCheckLabel, + govClaimApproveBlockedFor, + govClaimApproveConfirmBody, + govClaimApproveErrorMessage, + govClaimDecisionBlockedFor, +} from "./gov-claim-presentation" + +const PENDING_CHECK = { status: "pending", evidence: null, note: null } as const + +function claim(over: Partial = {}): GovClaimDTO { + return { + id: "claim-1", + name: "Dana Reyes", + title: "Deputy Director", + org: "City of Springfield", + jurisdictionGeoid: "0677000", + method: "email", + status: "pending", + age: "2d", + contactEmail: "dana@springfield.gov", + verified: [], + pending: [...GOV_CHECKS], + checks: { linkedin: PENDING_CHECK, directory: PENDING_CHECK, callback: PENDING_CHECK }, + ...over, + } +} + +describe("gov claim presentation", () => { + it("covers every wire claim status so a pill never renders blank", () => { + for (const status of GovClaimStatusSchema.options) { + expect(GOV_CLAIM_STATUS_VIEW[status].cls).toBeTruthy() + expect(GOV_CLAIM_STATUS_VIEW[status].label).toBeTruthy() + } + }) + + it("names every verification check from the shared label map", () => { + expect([...GOV_CHECKS]).toEqual(Object.keys(GOV_VERIFICATION_CHECK_LABELS)) + for (const check of GOV_CHECKS) { + expect(govCheckLabel(check)).toBe(GOV_VERIFICATION_CHECK_LABELS[check]) + } + }) +}) + +describe("gov claim decision availability", () => { + it("blocks a second decision, matching the API's 409 on a non-pending claim", () => { + expect(govClaimDecisionBlockedFor("approved")).toBe("This claim was already approved.") + expect(govClaimDecisionBlockedFor("rejected")).toBe("This claim was already rejected.") + }) + + it("allows the decision while the claim is still pending", () => { + expect(govClaimDecisionBlockedFor("pending")).toBeNull() + }) + + it("blocks approve when the claim has no contact email to grant access on", () => { + expect(govClaimApproveBlockedFor(claim({ contactEmail: "" }))).toBe( + "This claim has no contact email, so there is no account to grant government access to.", + ) + expect(govClaimApproveBlockedFor(claim({ contactEmail: " " }))).not.toBeNull() + expect(govClaimApproveBlockedFor(claim())).toBeNull() + expect(govClaimApproveBlockedFor(claim({ status: "approved" }))).toBe( + "This claim was already approved.", + ) + }) +}) + +describe("gov claim approve confirm", () => { + it("states the account grant, the session rule, the check state and the audit", () => { + const body = govClaimApproveConfirmBody(claim({ verified: ["linkedin"] }), GOV_CHECKS.length) + expect(body).toContain("grants government access to the account for dana@springfield.gov") + expect(body).toContain("creating a placeholder account first if that address has none yet") + expect(body).toContain("signed out only if its role actually changes") + expect(body).toContain("1 of 3 checks verified.") + expect(body).toContain("written to the audit log") + }) + + it("claims a jurisdiction link only when the claim carries a jurisdiction", () => { + expect(govClaimApproveConfirmBody(claim(), GOV_CHECKS.length)).toContain( + "links that account to jurisdiction 0677000", + ) + expect( + govClaimApproveConfirmBody(claim({ jurisdictionGeoid: null }), GOV_CHECKS.length), + ).not.toContain("jurisdiction") + }) + + it("surfaces an unverified approve in the check count", () => { + expect(govClaimApproveConfirmBody(claim(), GOV_CHECKS.length)).toContain( + "0 of 3 checks verified.", + ) + }) +}) + +describe("gov claim approve refusals", () => { + it("explains an unverified contact email", () => { + const err = new AppError(ErrorCode.VALIDATION, "Cannot elevate an existing account", { + fields: { contactEmail: "unverified" }, + }) + expect(govClaimApproveErrorMessage(err)).toContain("never proven they control it") + }) + + it("explains a blank contact email", () => { + const err = new AppError(ErrorCode.VALIDATION, "Cannot approve", { + fields: { contactEmail: "required" }, + }) + expect(govClaimApproveErrorMessage(err)).toContain("no contact email") + }) + + it("explains an operator address", () => { + const err = new AppError(ErrorCode.FORBIDDEN, "Operator accounts are managed elsewhere") + expect(govClaimApproveErrorMessage(err)).toContain("ADMIN_EMAILS") + }) + + it("falls back to the server message for anything else", () => { + const err = new AppError(ErrorCode.CONFLICT, "Gov claim is not pending") + expect(govClaimApproveErrorMessage(err)).toBe("Gov claim is not pending") + }) +}) diff --git a/apps/admin/src/features/moderation/gov-claim-presentation.ts b/apps/admin/src/features/moderation/gov-claim-presentation.ts new file mode 100644 index 0000000..40d3ee8 --- /dev/null +++ b/apps/admin/src/features/moderation/gov-claim-presentation.ts @@ -0,0 +1,91 @@ +import { + ErrorCode, + GOV_VERIFICATION_CHECK_LABELS, + type GovCheckStatus, + type GovClaimDTO, + type GovClaimStatus, + type GovMethod, + type GovVerificationCheck, +} from "@civfix/shared" + +import { errorMessage } from "@/lib/error-messages" + +/** Pill treatment per claim lifecycle status. */ +export const GOV_CLAIM_STATUS_VIEW: Record = { + pending: { cls: "status-new", label: "Pending" }, + approved: { cls: "status-ok", label: "Approved" }, + rejected: { cls: "status-flag", label: "Rejected" }, +} + +/** Pill treatment per verification check state. */ +export const GOV_CHECK_STATUS_VIEW: Record = { + verified: { cls: "status-ok", label: "Verified" }, + pending: { cls: "status-new", label: "Pending" }, +} + +/** How the applicant reached us. */ +export const GOV_METHOD_LABEL: Record = { + email: "Emailed us", + cold_outreach: "Cold outreach", +} + +/** The three verification checks, in the order the detail panel lists them. */ +export const GOV_CHECKS: readonly GovVerificationCheck[] = ["linkedin", "directory", "callback"] + +export function govCheckLabel(check: GovVerificationCheck): string { + return GOV_VERIFICATION_CHECK_LABELS[check] +} + +/** + * Why approve / reject are unavailable, or null when the claim can still be decided. The API refuses a + * decision on a claim that is no longer pending (409), so the buttons say so rather than failing. + */ +export function govClaimDecisionBlockedFor(status: GovClaimStatus): string | null { + if (status === "approved") return "This claim was already approved." + if (status === "rejected") return "This claim was already rejected." + return null +} + +export function govClaimApproveBlockedFor(claim: GovClaimDTO): string | null { + const lifecycle = govClaimDecisionBlockedFor(claim.status) + if (lifecycle !== null) return lifecycle + if (claim.contactEmail.trim() === "") { + return "This claim has no contact email, so there is no account to grant government access to." + } + return null +} + +export function govClaimApproveConfirmBody(claim: GovClaimDTO, checkCount: number): string { + return [ + `This grants government access to the account for ${claim.contactEmail}, creating a placeholder account first if that address has none yet.`, + claim.jurisdictionGeoid === null + ? null + : `The approved claim is what links that account to jurisdiction ${claim.jurisdictionGeoid}.`, + "That account's existing sessions are signed out only if its role actually changes.", + `${claim.verified.length} of ${checkCount} checks verified.`, + "It is written to the audit log.", + ] + .filter((line): line is string => line !== null) + .join(" ") +} + +export function govClaimApproveErrorMessage(error: unknown): string { + return errorMessage( + error, + { + [ErrorCode.FORBIDDEN]: + "That contact email belongs to an operator account. Operator accounts are managed through ADMIN_EMAILS and cannot be re-roled here — the applicant needs a different address.", + }, + { + fields: { + contactEmail: { + unverified: + "That address already has an account, but its owner has never proven they control it. They have to sign in with an email code first, then approve again.", + required: + "This claim has no contact email, so there is no account to grant government access to.", + }, + }, + fallback: "Could not approve this claim. Please try again.", + }, + ) +} diff --git a/apps/admin/src/features/moderation/gov-claims-views.tsx b/apps/admin/src/features/moderation/gov-claims-views.tsx new file mode 100644 index 0000000..eeb6711 --- /dev/null +++ b/apps/admin/src/features/moderation/gov-claims-views.tsx @@ -0,0 +1,355 @@ +"use client" + +import type { GovClaimDTO, GovVerificationCheck } from "@civfix/shared" + +import { Icons } from "@/components/icons" +import { EmptyState } from "@/components/shared/page-primitives" +import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { confirmDialog, promptDialog } from "@/components/shared/dialog" +import { isHttpsUrl } from "@/lib/external-url" +import { + GOV_CHECKS, + GOV_CHECK_STATUS_VIEW, + GOV_CLAIM_STATUS_VIEW, + GOV_METHOD_LABEL, + govCheckLabel, + govClaimApproveBlockedFor, + govClaimApproveConfirmBody, + govClaimDecisionBlockedFor, +} from "@/features/moderation/gov-claim-presentation" +import { + useApproveGovClaim, + useGovClaim, + useRejectGovClaim, + useVerifyGovClaimCheck, +} from "@/features/moderation/use-gov-claims" +import { useNav, useToast } from "@/store/ui-store" + +export function GovClaimRow({ + item, + selected, + onSelect, +}: { + item: GovClaimDTO + selected: boolean + onSelect: (id: string) => void +}) { + const status = GOV_CLAIM_STATUS_VIEW[item.status] ?? GOV_CLAIM_STATUS_VIEW.pending + return ( +
onSelect(item.id)}> + + + +
+
+ {item.name} + {item.org} +
+
+ {item.title} + · + + {item.verified.length} of {GOV_CHECKS.length} checks verified + +
+
+
+ {status.label} + {item.age} +
+
+ ) +} + +function CheckRow({ + claim, + check, + busy, + onToggle, +}: { + claim: GovClaimDTO + check: GovVerificationCheck + busy: boolean + onToggle: (check: GovVerificationCheck, next: "verified" | "pending") => void +}) { + const state = claim.checks[check] + const view = GOV_CHECK_STATUS_VIEW[state.status] ?? GOV_CHECK_STATUS_VIEW.pending + const verified = state.status === "verified" + const evidence = state.evidence ?? null + return ( +
+
+ {govCheckLabel(check)} + + {view.label} + +
+ {evidence && ( +
+ {isHttpsUrl(evidence) ? ( + + {evidence} + + ) : ( + evidence + )} +
+ )} + {state.note &&
{state.note}
} + +
+ ) +} + +export function GovClaimDetail({ + claimId, + onDecided, +}: { + claimId: string + onDecided: (id: string) => void +}) { + const q = useGovClaim(claimId) + const verifyCheck = useVerifyGovClaimCheck() + const approve = useApproveGovClaim() + const reject = useRejectGovClaim() + const toast = useToast() + const nav = useNav() + + const busy = verifyCheck.isPending || approve.isPending || reject.isPending + + if (q.isLoading) return + if (q.isError) return q.refetch()} /> + const claim = q.data + if (!claim) + return ( + } + /> + ) + + const status = GOV_CLAIM_STATUS_VIEW[claim.status] ?? GOV_CLAIM_STATUS_VIEW.pending + const decisionBlocked = govClaimDecisionBlockedFor(claim.status) + const approveBlocked = govClaimApproveBlockedFor(claim) + + const onToggleCheck = async (check: GovVerificationCheck, next: "verified" | "pending") => { + const stored = claim.checks[check] + const keepNote = stored.note ? { note: stored.note } : {} + if (next === "pending") { + verifyCheck.mutate( + { + id: claim.id, + check, + status: "pending", + ...(stored.evidence ? { evidence: stored.evidence } : {}), + ...keepNote, + }, + { onSuccess: () => toast(`${govCheckLabel(check)} · back to pending`) }, + ) + return + } + const evidence = await promptDialog({ + title: `Verify ${govCheckLabel(check)}`, + body: "Record what you checked. The evidence is stored on the claim and written to the audit log.", + label: "Evidence link or note (optional)", + defaultValue: stored.evidence ?? "", + confirmLabel: "Mark verified", + }) + if (evidence === null) return + verifyCheck.mutate( + { + id: claim.id, + check, + status: "verified", + ...(evidence.trim() ? { evidence: evidence.trim() } : {}), + ...keepNote, + }, + { onSuccess: () => toast(`${govCheckLabel(check)} · verified`) }, + ) + } + + const onApprove = async () => { + if (approveBlocked !== null) return + const ok = await confirmDialog({ + title: `Approve ${claim.name}?`, + body: govClaimApproveConfirmBody(claim, GOV_CHECKS.length), + confirmLabel: "Approve and provision", + }) + if (!ok) return + approve.mutate( + { id: claim.id }, + { + onSuccess: () => { + toast(`${claim.name} approved · government role provisioned`) + onDecided(claim.id) + }, + }, + ) + } + + const onReject = async () => { + const reason = await promptDialog({ + title: `Reject ${claim.name}?`, + body: "The reason is stored on the claim and written to the audit log.", + label: "Reason (required)", + placeholder: "The directory listing does not show this person in that department…", + confirmLabel: "Reject claim", + required: true, + danger: true, + }) + if (reason === null || reason.trim() === "") return + reject.mutate( + { id: claim.id, reason: reason.trim() }, + { + onSuccess: () => { + toast(`${claim.name} rejected`) + onDecided(claim.id) + }, + }, + ) + } + + return ( +
+
+ + + +
+
+ Gov provisioning · {GOV_METHOD_LABEL[claim.method]} ·{" "} + {claim.contactEmail} +
+

{claim.name}

+
+ + {status.label} + +
+ +
+
+
+
+ Verification checks + + {claim.verified.length} of {GOV_CHECKS.length} + +
+
+
+ {GOV_CHECKS.map((check) => ( + void onToggleCheck(c, next)} + /> + ))} +
+
+ Verify the applicant before approving — approval grants a government role on the + account behind the contact email. +
+
+
+
+ +
+
+
Applicant
+
+
+ + + +
+
{claim.name}
+
{claim.contactEmail}
+
+
+
+
+ Title + {claim.title} +
+
+ Organization + {claim.org} +
+
+ Reached us via + {GOV_METHOD_LABEL[claim.method]} +
+
+ Waiting + {claim.age} +
+
+ Jurisdiction + {claim.jurisdictionGeoid ?? "—"} +
+
+ {claim.jurisdictionGeoid && ( + + )} +
+
+
+
+ +
+ Decision + +
+ +
+ {decisionBlocked ? ( +
+ {decisionBlocked} Decisions are final; the applicant applies again + if something changed. +
+ ) : approveBlocked ? ( +
+ {approveBlocked} +
+ ) : null} +
+ ) +} diff --git a/apps/admin/src/features/moderation/moderation-page.tsx b/apps/admin/src/features/moderation/moderation-page.tsx index f898258..26484a6 100644 --- a/apps/admin/src/features/moderation/moderation-page.tsx +++ b/apps/admin/src/features/moderation/moderation-page.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { MODERATION_KIND_LABELS, + type GovClaimListQuery, type ModerationItemDTO, type ModerationKind, type ModerationListItemDTO, @@ -27,14 +28,16 @@ import { useModerationListInfinite, useRemoveModeration, } from "@/features/moderation/use-moderation" +import { useGovClaimListInfinite } from "@/features/moderation/use-gov-claims" +import { GovClaimDetail, GovClaimRow } from "@/features/moderation/gov-claims-views" import { useNav, useToast } from "@/store/ui-store" import type { SectionPageProps } from "@/components/shell/page-registry" type ServerFilter = NonNullable -const USER_REPORTS_CHIP = "user_reports" as const -type ChipValue = "all" | typeof USER_REPORTS_CHIP | ServerFilter +type Section = "queue" | "gov_claims" +type GovClaimFilter = NonNullable const PRIORITY_VIEW: Record = { low: { cls: "status-new", label: "Low" }, @@ -467,34 +470,131 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved: ) } -export function ModerationPage({ focusId }: SectionPageProps) { - const [filter, setFilter] = React.useState("all") +function GovClaimsSection() { + const [filter, setFilter] = React.useState("pending") const [query, setQuery] = React.useState("") - const [selId, setSelId] = React.useState(focusId) + const [selId, setSelId] = React.useState(null) const debouncedQuery = useDebounced(query, 250) + const listParams: GovClaimListQuery = { + ...(filter === "all" ? {} : { filter }), + ...(debouncedQuery.trim() ? { q: debouncedQuery.trim() } : {}), + } + const listQuery = useGovClaimListInfinite(listParams) + const items = React.useMemo( + () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + [listQuery.data], + ) + + React.useEffect(() => { + if (!selId && items.length) setSelId(items[0]!.id) + if (selId && items.length && !items.some((x) => x.id === selId)) setSelId(items[0]!.id) + }, [items, selId]) + + const onDecided = (id: string) => { + setSelId((cur) => (cur === id ? null : cur)) + } + + return ( + <> +
+ setFilter(v as GovClaimFilter)} + /> +
+
+ + setQuery(e.target.value)} + /> +
+
+ +
+
+
+

Gov claims

+
+ {items.length} +
+
+ {listQuery.isLoading ? ( + + ) : listQuery.isError ? ( + listQuery.refetch()} /> + ) : items.length === 0 ? ( + } + /> + ) : ( + <> + {items.map((c) => ( + + ))} + {listQuery.hasNextPage && ( + + )} + + )} +
+
+ +
+ {selId ? ( + + ) : ( + } + /> + )} +
+
+ + ) +} + +function ModerationQueueSection({ focusId }: SectionPageProps) { + const [filter, setFilter] = React.useState<"all" | ServerFilter>("all") + const [query, setQuery] = React.useState("") + const [selId, setSelId] = React.useState(focusId) - const serverFilter: ServerFilter | undefined = - filter === "all" || filter === USER_REPORTS_CHIP ? undefined : filter + const debouncedQuery = useDebounced(query, 250) const listParams: ModerationListQuery = { - ...(serverFilter ? { filter: serverFilter } : {}), + ...(filter === "all" ? {} : { filter }), ...(debouncedQuery.trim() ? { q: debouncedQuery.trim() } : {}), } const listQuery = useModerationListInfinite(listParams) - const items = React.useMemo(() => { - const all = listQuery.data?.pages.flatMap((p) => p.items) ?? [] - return filter === USER_REPORTS_CHIP ? all.filter((x) => x.kind === "user_report") : all - }, [listQuery.data, filter]) - - const allParams: ModerationListQuery = debouncedQuery.trim() ? { q: debouncedQuery.trim() } : {} - const allForCount = useModerationListInfinite(allParams) - const userReportCount = React.useMemo( - () => - (allForCount.data?.pages.flatMap((p) => p.items) ?? []).filter( - (x) => x.kind === "user_report", - ).length, - [allForCount.data], + const items = React.useMemo( + () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + [listQuery.data], ) React.useEffect(() => { @@ -511,22 +611,11 @@ export function ModerationPage({ focusId }: SectionPageProps) { return ( <> - - The moderation queue — citizen content reports (the in-app “Report” button) plus - held media, coordinated-report clusters, and appeals. Review the signals, then approve, - remove, hold, or decide the appeal. - - } - /> -
setFilter(v as ChipValue)} + onChange={(v) => setFilter(v as "all" | ServerFilter)} />
@@ -608,3 +697,51 @@ export function ModerationPage({ focusId }: SectionPageProps) { ) } + +export function ModerationPage({ focusId }: SectionPageProps) { + const [section, setSection] = React.useState
("queue") + const queue = section === "queue" + + return ( + <> + + The moderation queue — citizen content reports (the in-app “Report” button) + plus held media, coordinated-report clusters, and appeals. Review the signals, then + approve, remove, hold, or decide the appeal. + + ) : ( + + Government staff asking for access to their jurisdiction. Verify who they are, then + approve — which provisions a government role on their account — or reject with a reason. + + ) + } + /> + +
+ + +
+ + {queue ? : } + + ) +} diff --git a/apps/admin/src/features/moderation/use-gov-claims.ts b/apps/admin/src/features/moderation/use-gov-claims.ts new file mode 100644 index 0000000..b32d43a --- /dev/null +++ b/apps/admin/src/features/moderation/use-gov-claims.ts @@ -0,0 +1,78 @@ +"use client" + +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import type { + ApproveGovClaimRequest, + GetGovClaimResponse, + GovClaimListQuery, + GovClaimListResponse, + RejectGovClaimRequest, + VerifyCheckRequest, +} from "@civfix/shared" + +import { api } from "@/lib/api" +import { queryKeys } from "@/lib/query" +import { govClaimApproveErrorMessage } from "@/features/moderation/gov-claim-presentation" +import { useToast } from "@/store/ui-store" + +/** + * Data hooks for the gov-provisioning queue (GET/POST /admin/gov-claims*). An operator verifies the + * applicant's LinkedIn / municipal directory / phone callback, then approves — which provisions the + * government role on the contact email's account and links the jurisdiction — or rejects with a reason. + * Approve therefore also invalidates the users caches, since it changes an account's role. + */ + +export function useGovClaimListInfinite(params: GovClaimListQuery) { + return useInfiniteQuery({ + queryKey: queryKeys.govClaims.list(params), + queryFn: ({ pageParam }) => + api.listGovClaims({ + ...params, + ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }) +} + +export function useGovClaim(id: string | null) { + return useQuery({ + queryKey: queryKeys.govClaims.detail(id ?? ""), + queryFn: () => api.getGovClaim({ id: id as string }), + enabled: !!id, + }) +} + +function invalidateGovClaims(qc: ReturnType, id: string) { + qc.invalidateQueries({ queryKey: queryKeys.govClaims.detail(id) }) + qc.invalidateQueries({ queryKey: queryKeys.govClaims.all }) +} + +export function useVerifyGovClaimCheck() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: VerifyCheckRequest) => api.verifyGovClaim(input), + onSuccess: (_res, { id }) => invalidateGovClaims(qc, id), + }) +} + +export function useApproveGovClaim() { + const qc = useQueryClient() + const toast = useToast() + return useMutation({ + mutationFn: (input: ApproveGovClaimRequest) => api.approveGovClaim(input), + onError: (error) => toast(govClaimApproveErrorMessage(error)), + onSuccess: (_res, { id }) => { + invalidateGovClaims(qc, id) + qc.invalidateQueries({ queryKey: queryKeys.users.all }) + }, + }) +} + +export function useRejectGovClaim() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: RejectGovClaimRequest) => api.rejectGovClaim(input), + onSuccess: (_res, { id }) => invalidateGovClaims(qc, id), + }) +} diff --git a/apps/admin/src/features/moderation/use-moderation.ts b/apps/admin/src/features/moderation/use-moderation.ts index 55bca1b..df847b0 100644 --- a/apps/admin/src/features/moderation/use-moderation.ts +++ b/apps/admin/src/features/moderation/use-moderation.ts @@ -47,7 +47,6 @@ function invalidateModeration(qc: ReturnType, id?: string if (id) qc.invalidateQueries({ queryKey: queryKeys.moderation.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.moderation.all }) qc.invalidateQueries({ queryKey: queryKeys.home.all }) - qc.invalidateQueries({ queryKey: queryKeys.activity.all }) } export function useApproveModeration() { diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index f98ddf2..2cdbc68 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic" import { ADMIN_REPORT_STATUS_LABELS, ADMIN_REPORT_STATUS_TRANSITIONS, + MESSAGE_BODY_MAX, REPORT_CATEGORY_LABELS, type AdminReportCounts, type AdminReportDTO, @@ -23,17 +24,18 @@ import { categoryCssVar, categoryPinSrc } from "@/lib/category" import { reportStatusView } from "@/lib/report-status" import { eventKindView } from "@/lib/event-kind" import { getReporterProfileId } from "@/features/reports/reporter-navigation" -import { routeActionFor, type RouteAction } from "@/features/reports/route-action" +import { routeActionFor, routeSendLabel, type RouteAction } from "@/features/reports/route-action" import { useDebounced } from "@/hooks/use-debounced" import { - useDeleteReportMessage, useFlagReport, useRemoveReport, + useRemoveReportMessage, useReport, useReportChatHistory, useReportListInfinite, useRouteReport, useSendReportFollowup, + useSendReportMessage, useSetReportStatus, useSetReportVerdict, } from "@/features/reports/use-reports" @@ -46,7 +48,15 @@ const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) loading: () =>
, }) -type ReportFilter = keyof AdminReportCounts +type ReportFilter = "needs_verification" | "in_progress" | "completed" | "flagged" | "all" + +const FILTER_COUNT_KEY: Record = { + needs_verification: "needsVerification", + in_progress: "in_progress", + completed: "completed", + flagged: "flagged", + all: "all", +} const TL_ICON: Record = { submit: Icons.Pin, @@ -135,12 +145,24 @@ function msgWhen(iso: string): string { return Number.isNaN(d.getTime()) ? iso : d.toLocaleString() } -function routeButtonLabel(action: RouteAction): string { +function routeButtonLabel(action: RouteAction, verdictApproved: boolean): string { if (action.kind === "already_sent") { return action.routedAt ? `Already sent · ${msgWhen(action.routedAt)}` : "Already sent" } if (action.kind === "resend") return "Send again to jurisdiction" - return "Approve & send to jurisdiction" + return routeSendLabel(verdictApproved) +} + +const VERDICT_PILL = { + approved: { cls: "status-ok", label: "Approved" }, + rejected: { cls: "status-flag", label: "Rejected" }, + unreviewed: { cls: "status-new", label: "Not yet reviewed" }, +} as const + +function verdictPill(verdict: AdminReportDTO["verificationVerdict"], approvedLocally: boolean) { + if (verdict === "approved" || approvedLocally) return VERDICT_PILL.approved + if (verdict === "rejected") return VERDICT_PILL.rejected + return VERDICT_PILL.unreviewed } function chatAuthorName(msg: ChatMessageDTO): string { @@ -284,16 +306,26 @@ function ChatMessageRow({ } /** - * Read-only report chat, as neighbors see it: the same messages from the citizen chat history - * endpoint, including sender-less SYSTEM status events. Operators can't post — they observe and - * moderate. Delete goes through the report chat DELETE endpoint (soft-delete) and is gated to - * non-system rows (a status event has no author and can't be removed). + * The report chat as neighbors see it, read through the ADMIN plane, including sender-less SYSTEM status + * events. Operators moderate here and can post into the same public thread. Remove goes through the + * admin remove endpoint (soft-delete) and is gated to non-system rows (a status event has no author and + * can't be removed). */ -function ReportDiscussion({ reportId }: { reportId: string }) { +function ReportDiscussion({ + reportId, + cityDept, + hasCityContact, +}: { + reportId: string + cityDept: string + hasCityContact: boolean +}) { const q = useReportChatHistory(reportId) - const removeMsg = useDeleteReportMessage() + const removeMsg = useRemoveReportMessage() + const sendMsg = useSendReportMessage() const toast = useToast() const nav = useNav() + const [draft, setDraft] = React.useState("") const onRemove = async (msg: ChatMessageDTO) => { const ok = await confirmDialog({ @@ -309,6 +341,20 @@ function ReportDiscussion({ reportId }: { reportId: string }) { ) } + const onSend = () => { + const body = draft.trim() + if (!body || sendMsg.isPending) return + sendMsg.mutate( + { id: reportId, body }, + { + onSuccess: () => { + setDraft("") + toast("Message posted to the report chat") + }, + }, + ) + } + // Oldest → newest, matching the order neighbors see in the chat. const items = React.useMemo(() => { const list = [...(q.data?.items ?? [])] @@ -360,6 +406,36 @@ function ReportDiscussion({ reportId }: { reportId: string }) {
)}
+
+