feat(incidents): anonymous report tracking by token + public /track

Gap: fileIncident minted a reporter_token and the filing UI showed it
("the only way to look it up later") but there was no lookup UI.

- lib/incident-logic.ts (pure, tested): publicIncidentView redacts —
  board_only updates, subjects, decision-maker ids dropped; disposition
  only surfaced once decided/closed; status label mapping
- trackIncident: public service-client action (token is a 192-bit
  bearer credential on a UNIQUE column; anon has no RLS path so the
  service client is used AFTER token validation; generic not-found is
  enumeration-safe; board_only filtered at the query AND in logic)
- public /track page + client (also accepts ?token= deep link);
  /track added to middleware PUBLIC_ROUTES
- filing UI now deep-links /track?token=<code>
- trackIncidentSchema; __tests__/incident-tracking.test.ts (6)
- docs: API_REFERENCE + HANDOFF pass-20 (session arc, web-verified
  integration facts, locked forward roadmap, open items incl. the
  still-open production incident-INSERT RLS bug)

343 tests; build verified (Compiled successfully, exit 0).
This commit is contained in:
Moheeb Zara
2026-05-16 18:13:09 -07:00
parent 2559166092
commit ad477252c8
10 changed files with 417 additions and 0 deletions
+38
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import {
requireMember,
requireMemberWithRole,
@@ -14,7 +15,9 @@ import {
updateIncidentStatusSchema,
addIncidentUpdateSchema,
appealIncidentSchema,
trackIncidentSchema,
} from '@/lib/validations'
import { publicIncidentView } from '@/lib/incident-logic'
/**
* Generate a short opaque tracking token for anonymous reports.
@@ -254,3 +257,38 @@ export async function appealIncident(formData: {
revalidatePath(`/proposals/${proposal.id}`)
return { data: proposal }
}
/**
* Public, unauthenticated lookup of an anonymously-filed incident by its
* opaque reporter token. No session: the token IS the bearer credential, so
* this uses the service client (anon has no RLS path to incidents) AFTER
* validating the token, exactly like the forms public read. The returned
* projection is redacted by `publicIncidentView` — board-only updates,
* subjects, decision-maker ids and an undecided disposition never leave the
* server. The token is 192 bits of randomness on a UNIQUE column, so a
* generic not-found response is safe against enumeration.
*/
export async function trackIncident(input: unknown) {
const v = parseInput(trackIncidentSchema, input)
if (!v.ok) return { error: v.error }
const admin = createAdminClient()
const { data: incident } = await admin
.from('incidents')
.select(
'id, title, body, category, severity, status, disposition, created_at, acknowledged_at, decided_at, closed_at',
)
.eq('reporter_token', v.data.token)
.maybeSingle()
if (!incident) return { error: 'No report matches that tracking code.' }
const { data: updates } = await admin
.from('incident_updates')
.select('body, author_name, visibility, created_at')
.eq('incident_id', incident.id)
.neq('visibility', 'board_only')
.order('created_at', { ascending: true })
return { data: publicIncidentView(incident, updates ?? []) }
}
+83
View File
@@ -0,0 +1,83 @@
// Pure projection logic for the anonymous incident tracking page. No deps so
// it is unit-testable. The single rule that matters here is REDACTION: a
// holder of a reporter token is not authenticated and must never see
// board-only notes, internal decision-maker ids, named subjects, or anything
// beyond their own report's status and the official updates meant for them.
export const INCIDENT_STATUS_LABELS: Record<string, string> = {
received: 'Received',
under_review: 'Under review',
decided: 'Decided',
appealed: 'Appealed',
closed: 'Closed',
}
// Updates a token holder may see. board_only is intentionally excluded.
const VISIBLE_UPDATE_VISIBILITIES = ['reporter_only', 'all_parties'] as const
export interface RawIncident {
title: string
body: string
category: string
severity: string
status: string
disposition: string | null
created_at: string
acknowledged_at: string | null
decided_at: string | null
closed_at: string | null
}
export interface RawUpdate {
body: string
author_name: string | null
visibility: string
created_at: string
}
export interface PublicIncidentView {
title: string
body: string
category: string
severity: string
status: string
statusLabel: string
createdAt: string
acknowledgedAt: string | null
decidedAt: string | null
closedAt: string | null
// Only surfaced once a decision exists — never leak an in-progress lean.
disposition: string | null
updates: { body: string; author: string; createdAt: string }[]
}
export function visibleUpdate(visibility: string): boolean {
return (VISIBLE_UPDATE_VISIBILITIES as readonly string[]).includes(visibility)
}
export function publicIncidentView(
incident: RawIncident,
updates: RawUpdate[],
): PublicIncidentView {
const decisionReached = incident.status === 'decided' || incident.status === 'closed'
return {
title: incident.title,
body: incident.body,
category: incident.category,
severity: incident.severity,
status: incident.status,
statusLabel: INCIDENT_STATUS_LABELS[incident.status] ?? incident.status,
createdAt: incident.created_at,
acknowledgedAt: incident.acknowledged_at,
decidedAt: incident.decided_at,
closedAt: incident.closed_at,
disposition: decisionReached ? incident.disposition : null,
updates: updates
.filter(u => visibleUpdate(u.visibility))
.map(u => ({
body: u.body,
author: u.author_name ?? 'Space team',
createdAt: u.created_at,
})),
}
}
+7
View File
@@ -289,6 +289,13 @@ export const appealIncidentSchema = z.object({
body: z.string().max(20000).default(''),
})
// Anonymous reporters look their case up with the opaque token they were
// given at filing time (48 hex chars today; bound loosely so the format can
// evolve without breaking old tokens).
export const trackIncidentSchema = z.object({
token: z.string().trim().min(16).max(128),
})
// ─── Governance: policies ────────────────────────────────────────────────────
export const policyStatuses = ['draft','active','deprecated','superseded'] as const