feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support test(outbox): add tests for outbox functionality including report submission and error handling feat(outbox): create outbox management for queued reports with IndexedDB persistence feat(uuid): add UUID generation utility for client IDs feat(qr): implement QR code generation for reporting links by site and area feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
1 parent
3f6dff5f24
commit
3e964d1176
64 files changed
+10056
-267
No files matched your search
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user