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