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
@@ -11,6 +11,7 @@ import { actionRoutes } from './routes/actions.ts'
|
||||
import { attachmentRoutes } from './routes/attachments.ts'
|
||||
import { authRoutes } from './routes/auth.ts'
|
||||
import { incidentRoutes } from './routes/incidents.ts'
|
||||
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
|
||||
import { rootCauseRoutes } from './routes/root-causes.ts'
|
||||
import { setupRoutes } from './routes/setup.ts'
|
||||
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||
@@ -54,6 +55,8 @@ export function createApp({ db, config, notifier, storage }: AppDeps) {
|
||||
app.route('/api/actions', actionRoutes)
|
||||
app.route('/api/attachments', attachmentRoutes)
|
||||
app.route('/api/stats', statsRoutes)
|
||||
app.route('/api/kiosks', kioskAdminRoutes)
|
||||
app.route('/api/kiosk', kioskPublicRoutes())
|
||||
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||
|
||||
app.onError((error, c) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
|
||||
CREATE TABLE "kiosks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"site_id" uuid,
|
||||
"created_by_id" uuid,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
||||
"when": 1791309275942,
|
||||
"tag": "0001_workflow_and_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1791310953907,
|
||||
"tag": "0002_kiosks_and_offline",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCau
|
||||
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
|
||||
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
|
||||
|
||||
const id = () => uuid().primaryKey().defaultRandom()
|
||||
const timestamptz = () => timestamp({ withTimezone: true })
|
||||
@@ -115,6 +116,29 @@ export const areas = pgTable(
|
||||
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
||||
)
|
||||
|
||||
/**
|
||||
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
|
||||
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
|
||||
*/
|
||||
export const kiosks = pgTable(
|
||||
'kiosks',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
name: text().notNull(),
|
||||
token: text().notNull(),
|
||||
/** Limits the link to one site; null allows any. */
|
||||
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
|
||||
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: timestamptz(),
|
||||
revokedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
|
||||
)
|
||||
|
||||
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
||||
export const incidentCounters = pgTable('incident_counters', {
|
||||
organizationId: uuid()
|
||||
@@ -146,12 +170,19 @@ export const incidents = pgTable(
|
||||
/** Null when reported anonymously. */
|
||||
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
anonymous: boolean().notNull().default(false),
|
||||
/** Name typed by someone reporting without an account (kiosk/QR). */
|
||||
reporterName: text(),
|
||||
channel: reportChannelEnum().notNull().default('web'),
|
||||
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
|
||||
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
|
||||
clientId: uuid(),
|
||||
closedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
||||
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
|
||||
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
||||
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
||||
index('incidents_reporter_idx').on(t.reporterId),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { attachments } from '../db/schema.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||
|
||||
/** Validates and stores one uploaded file against an incident. */
|
||||
export async function saveAttachment(
|
||||
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
|
||||
incidentId: string,
|
||||
file: unknown,
|
||||
uploadedById: string | null,
|
||||
) {
|
||||
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
|
||||
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
|
||||
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
|
||||
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const type = detectFileType(data)
|
||||
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
|
||||
|
||||
const storageKey = await storage.save(data, type.extension)
|
||||
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(attachments)
|
||||
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
|
||||
.returning()
|
||||
return row
|
||||
} catch (error) {
|
||||
await storage.remove(storageKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { incidentReference } from '../../shared/format.ts'
|
||||
import { availableTransitions } from '../../shared/workflow.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
@@ -15,16 +15,17 @@ const reporters = alias(users, 'reporter')
|
||||
|
||||
export function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
|
||||
}
|
||||
|
||||
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||
|
||||
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
|
||||
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
@@ -44,6 +45,9 @@ export function toIncident({ incident, siteName, areaName, reporterName }: Incid
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||
reporterName: incident.reporterName,
|
||||
channel: incident.channel,
|
||||
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+12
-79
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { z } from 'zod'
|
||||
@@ -19,42 +19,19 @@ import { transitionBlocker, transitions } from '../../shared/workflow.ts'
|
||||
import { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { toCsv } from '../lib/csv.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { detectFileType } from '../lib/file-type.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
|
||||
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
|
||||
|
||||
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||
|
||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||
const EXPORT_LIMIT = 10_000
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||
@@ -122,7 +99,7 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
i.site.name,
|
||||
i.area?.name,
|
||||
i.locationDetail,
|
||||
i.anonymous ? 'Anonymous' : i.reporter?.name,
|
||||
i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
|
||||
i.description,
|
||||
i.immediateAction,
|
||||
]),
|
||||
@@ -133,34 +110,11 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
|
||||
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { anonymous, occurredAt, ...input } = c.req.valid('json')
|
||||
const input = c.req.valid('json')
|
||||
const db = c.get('db')
|
||||
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
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: me.organizationId, lastNumber: 1 })
|
||||
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||
const [incident] = await tx
|
||||
.insert(incidents)
|
||||
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
|
||||
.returning({ id: incidents.id, number: incidents.number })
|
||||
await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', {
|
||||
number: lastNumber,
|
||||
title: input.title,
|
||||
type: input.type,
|
||||
severity: input.severity,
|
||||
})
|
||||
return incident
|
||||
})
|
||||
|
||||
c.get('notifier').incidentReported(created.id)
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
|
||||
return c.json({ incident: toIncident(row) }, 201)
|
||||
const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, id))
|
||||
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
|
||||
})
|
||||
|
||||
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||
@@ -330,29 +284,8 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
|
||||
const body = await c.req.parseBody()
|
||||
const file = body.file
|
||||
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
|
||||
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
|
||||
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incident.id))
|
||||
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const type = detectFileType(data)
|
||||
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
|
||||
|
||||
const storage = c.get('storage')
|
||||
const storageKey = await storage.save(data, type.extension)
|
||||
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(attachments)
|
||||
.values({ incidentId: incident.id, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById: me.id })
|
||||
.returning()
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName })
|
||||
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||
} catch (error) {
|
||||
await storage.remove(storageKey)
|
||||
throw error
|
||||
}
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let north: { id: string }
|
||||
let south: { id: string }
|
||||
|
||||
const json = (res: Response): Promise<any> => res.json()
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||
|
||||
/** Submits like the kiosk page: multipart with the token header. */
|
||||
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
|
||||
const form = new FormData()
|
||||
form.append('report', JSON.stringify(report))
|
||||
for (const file of files) form.append('files', file)
|
||||
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
|
||||
}
|
||||
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
north = await ctx.createSite('North')
|
||||
south = await ctx.createSite('South')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('reporting links', () => {
|
||||
it('are managed by safety managers', async () => {
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
|
||||
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
|
||||
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
|
||||
})
|
||||
|
||||
it('let anyone with the token file a report, with photos, and nothing more', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
|
||||
|
||||
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
|
||||
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
|
||||
|
||||
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
|
||||
expect(res.status).toBe(201)
|
||||
const { reference, failedFiles } = await json(res)
|
||||
expect(reference).toMatch(/^INC-\d{4}$/)
|
||||
expect(failedFiles).toEqual([])
|
||||
|
||||
const number = Number(reference.slice(4))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
|
||||
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
|
||||
// The token is not a session
|
||||
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('treat reports without a name as anonymous', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
|
||||
const { reference } = await json(await kioskReport(kiosk.token, report()))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
|
||||
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
|
||||
})
|
||||
|
||||
it('stop working when revoked', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
|
||||
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
|
||||
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
|
||||
expect((await kioskReport('made-up-token', report())).status).toBe(401)
|
||||
})
|
||||
|
||||
it('can be limited to one site', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
|
||||
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
|
||||
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
|
||||
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('validate reports', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
|
||||
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await json(res)).issues[0].path).toBe('type')
|
||||
})
|
||||
|
||||
it('are rate limited', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
|
||||
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
|
||||
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubmitting the same report (offline retries)', () => {
|
||||
it('returns the original instead of creating a duplicate', async () => {
|
||||
const reporter = await ctx.createUser('reporter')
|
||||
const clientId = crypto.randomUUID()
|
||||
const first = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
const again = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
expect(first.status).toBe(201)
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.body.incident.number).toBe(first.body.incident.number)
|
||||
})
|
||||
|
||||
it('holds under simultaneous retries', async () => {
|
||||
const reporter = await ctx.createUser('reporter', 'racer@example.com')
|
||||
const clientId = crypto.randomUUID()
|
||||
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
|
||||
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
|
||||
})
|
||||
|
||||
it('does not attach photos twice from a kiosk retry', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
|
||||
const clientId = crypto.randomUUID()
|
||||
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
|
||||
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
|
||||
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
|
||||
expect(b.status).toBe(200)
|
||||
expect((await json(b)).reference).toBe(a.reference)
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident } from '../lib/incidents.ts'
|
||||
import { fileReport } from '../lib/reporting.ts'
|
||||
|
||||
export const KIOSK_HEADER = 'x-kiosk-token'
|
||||
const MAX_FILES_PER_REPORT = 5
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
|
||||
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
|
||||
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
|
||||
}
|
||||
|
||||
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
|
||||
if (!siteId) return
|
||||
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
}
|
||||
|
||||
/** Managing reporting links (signed-in safety managers). */
|
||||
export const kioskAdminRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireRole('safety_manager'), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
|
||||
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
|
||||
})
|
||||
|
||||
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const input = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, input.siteId)
|
||||
const [row] = await db
|
||||
.insert(kiosks)
|
||||
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
|
||||
.returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
|
||||
return c.json({ kiosk: await toKiosk(db, row) }, 201)
|
||||
})
|
||||
|
||||
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
|
||||
if (!current) throw fail(404, 'Reporting link not found')
|
||||
const { revoked, ...fields } = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, fields.siteId)
|
||||
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
|
||||
const changes = diff(current, update)
|
||||
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
|
||||
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
|
||||
return c.json({ kiosk: await toKiosk(db, row) })
|
||||
})
|
||||
|
||||
/**
|
||||
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
|
||||
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
|
||||
* from another site.
|
||||
*/
|
||||
export function kioskPublicRoutes() {
|
||||
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
|
||||
const limiter = new RateLimiter(30, 10 * 60 * 1000)
|
||||
|
||||
async function kioskFrom(c: Context<AppEnv>) {
|
||||
const token = c.req.header(KIOSK_HEADER)
|
||||
if (!token) throw fail(401, 'Missing reporting link token')
|
||||
const [row] = await c
|
||||
.get('db')
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
|
||||
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
|
||||
return row
|
||||
}
|
||||
|
||||
return new Hono<AppEnv>()
|
||||
.get('/config', async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
const db = c.get('db')
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
|
||||
const siteRows = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
|
||||
.orderBy(asc(sites.name))
|
||||
const areaRows = siteRows.length
|
||||
? await db
|
||||
.select()
|
||||
.from(areas)
|
||||
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
|
||||
.orderBy(asc(areas.name))
|
||||
: []
|
||||
const config: KioskConfig = {
|
||||
organization,
|
||||
kiosk: { name: kiosk.name },
|
||||
sites: siteRows.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: null,
|
||||
timezone: site.timezone,
|
||||
archivedAt: null,
|
||||
areas: areaRows
|
||||
.filter((area) => area.siteId === site.id)
|
||||
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
|
||||
})),
|
||||
}
|
||||
return c.json(config)
|
||||
})
|
||||
|
||||
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
|
||||
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
|
||||
const db = c.get('db')
|
||||
|
||||
const body = await c.req.parseBody({ all: true })
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(String(body.report ?? ''))
|
||||
} catch {
|
||||
throw fail(400, 'Missing report')
|
||||
}
|
||||
const parsed = kioskReportSchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
|
||||
}
|
||||
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
|
||||
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
|
||||
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
|
||||
|
||||
const { reporterName, ...report } = parsed.data
|
||||
const { id, number, created } = await fileReport(db, c.get('notifier'), {
|
||||
...report,
|
||||
organizationId: kiosk.organizationId,
|
||||
reporter: null,
|
||||
reporterName,
|
||||
anonymous: !reporterName,
|
||||
kioskId: kiosk.id,
|
||||
})
|
||||
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
|
||||
|
||||
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
|
||||
const failed: string[] = []
|
||||
if (created) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
|
||||
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
} catch (error) {
|
||||
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the reference goes back: people using a link can't read reports.
|
||||
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user