import { and, eq, isNull, sql } from 'drizzle-orm' import type { IncidentType, Severity } from '../../shared/enums.ts' import type { SessionUser } from '../auth/sessions.ts' import { isUniqueViolation, type Db } from '../db/client.ts' import { areas, incidentCounters, incidents, sites } from '../db/schema.ts' import type { Notifier } from '../notifications.ts' import { fail } from './http.ts' import { auditIncident } from './incidents.ts' /** Reports may be dated slightly ahead to absorb clock differences between devices. */ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 export function parseOccurredAt(value: string): Date { const date = new Date(value) if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future') return date } export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) { const [site] = await db .select({ id: sites.id }) .from(sites) .where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt))) if (!site) throw fail(400, 'Unknown site') if (areaId) { const [area] = await db .select({ id: areas.id }) .from(areas) .where(and(eq(areas.id, areaId), eq(areas.siteId, siteId))) if (!area) throw fail(400, 'Area must belong to the selected site') } } export interface NewReport { organizationId: string title: string type: IncidentType severity: Severity occurredAt: string siteId: string areaId?: string | null locationDetail?: string | null description?: string | null immediateAction?: string | null clientId?: string /** Signed-in reporter, or null for anonymous and kiosk reports. */ reporter: SessionUser | null reporterName?: string | null anonymous: boolean kioskId?: string } /** * Files a report with the next incident number. If `clientId` was already used in the organization * (a retried submission), returns the existing incident instead of creating a duplicate. */ export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> { const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report await assertLocation(db, organizationId, fields.siteId, fields.areaId) const occurred = parseOccurredAt(occurredAt) const existing = async () => { if (!clientId) return undefined const [row] = await db .select({ id: incidents.id, number: incidents.number }) .from(incidents) .where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId))) return row && { ...row, created: false } } const previous = await existing() if (previous) return previous try { const created = await db.transaction(async (tx) => { // Atomic increment: concurrent reports always get distinct, gap-free numbers. const [{ lastNumber }] = await tx .insert(incidentCounters) .values({ organizationId, lastNumber: 1 }) .onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } }) .returning({ lastNumber: incidentCounters.lastNumber }) const [incident] = await tx .insert(incidents) .values({ ...fields, occurredAt: occurred, organizationId, number: lastNumber, clientId, anonymous, reporterId: anonymous ? null : (reporter?.id ?? null), reporterName: anonymous ? null : (fields.reporterName ?? null), channel: kioskId ? 'kiosk' : 'web', kioskId, }) .returning({ id: incidents.id, number: incidents.number }) await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', { number: lastNumber, title: fields.title, type: fields.type, severity: fields.severity, ...(kioskId ? { kioskId } : {}), }) return incident }) notifier.incidentReported(created.id) return { ...created, created: true } } catch (error) { // Two copies of the same submission raced; the other one won. if (clientId && isUniqueViolation(error)) { const winner = await existing() if (winner) return winner } throw error } }