feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions. - Create tests for permissions functionality. - Implement queries for user authentication and actions management. - Develop Actions page to display and manage corrective actions. - Create authentication pages for login, setup, and password reset. - Add IncidentDetail page to view incident details and associated actions. - Implement Settings page for user management and password changes.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7213
-643
No files matched your search
+25
-2
@@ -6,19 +6,38 @@ import { loadSession } from './auth/middleware.ts'
|
||||
import type { Config } from './config.ts'
|
||||
import type { Db } from './db/client.ts'
|
||||
import type { AppEnv } from './lib/http.ts'
|
||||
import type { Notifier } from './notifications.ts'
|
||||
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 { rootCauseRoutes } from './routes/root-causes.ts'
|
||||
import { setupRoutes } from './routes/setup.ts'
|
||||
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||
import { statsRoutes } from './routes/stats.ts'
|
||||
import { userRoutes } from './routes/users.ts'
|
||||
import type { FileStorage } from './storage.ts'
|
||||
|
||||
export function createApp({ db, config }: { db: Db; config: Config }) {
|
||||
export interface AppDeps {
|
||||
db: Db
|
||||
config: Config
|
||||
notifier: Notifier
|
||||
storage: FileStorage
|
||||
}
|
||||
|
||||
export function createApp({ db, config, notifier, storage }: AppDeps) {
|
||||
const app = new Hono<AppEnv>()
|
||||
|
||||
app.use('*', secureHeaders())
|
||||
app.use('/api/*', csrf(), async (c, next) => {
|
||||
const publicOrigin = new URL(config.publicUrl).origin
|
||||
// Form/multipart posts must come from our own pages. JSON requests are already protected by CORS preflight.
|
||||
const sameSite = csrf({ origin: (origin, c) => origin === publicOrigin || origin === new URL(c.req.url).origin })
|
||||
|
||||
app.use('/api/*', sameSite, async (c, next) => {
|
||||
c.set('db', db)
|
||||
c.set('config', config)
|
||||
c.set('notifier', notifier)
|
||||
c.set('storage', storage)
|
||||
c.header('Cache-Control', 'no-store')
|
||||
await next()
|
||||
})
|
||||
@@ -31,6 +50,10 @@ export function createApp({ db, config }: { db: Db; config: Config }) {
|
||||
app.route('/api/sites', siteRoutes)
|
||||
app.route('/api/areas', areaRoutes)
|
||||
app.route('/api/incidents', incidentRoutes)
|
||||
app.route('/api/root-causes', rootCauseRoutes)
|
||||
app.route('/api/actions', actionRoutes)
|
||||
app.route('/api/attachments', attachmentRoutes)
|
||||
app.route('/api/stats', statsRoutes)
|
||||
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||
|
||||
app.onError((error, c) => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { authTokens, users } from '../db/schema.ts'
|
||||
|
||||
export type TokenPurpose = 'magic_link' | 'password_reset'
|
||||
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex')
|
||||
|
||||
export async function createAuthToken(db: Db, userId: string, purpose: TokenPurpose, ttlMs: number): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
await db.insert(authTokens).values({ id: hash(token), userId, purpose, expiresAt: new Date(Date.now() + ttlMs) })
|
||||
return token
|
||||
}
|
||||
|
||||
/** Marks the token used and returns its (active) user's id, or null if invalid, expired or already used. */
|
||||
export async function consumeAuthToken(db: Db, token: string, purpose: TokenPurpose): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.update(authTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(and(eq(authTokens.id, hash(token)), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt), gt(authTokens.expiresAt, new Date())))
|
||||
.returning({ userId: authTokens.userId })
|
||||
if (!row) return null
|
||||
const [user] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, row.userId), eq(users.active, true)))
|
||||
return user?.id ?? null
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export interface Config {
|
||||
production: boolean
|
||||
/** Built web app to serve, if present. */
|
||||
staticDir: string
|
||||
/** e.g. `smtps://user:pass@smtp.example.com:465`. Without it, emails are printed to the log (development only). */
|
||||
smtpUrl: string | undefined
|
||||
mailFrom: string
|
||||
uploadDir: string
|
||||
maxUploadBytes: number
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
@@ -18,5 +23,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
secureCookies: publicUrl.startsWith('https://'),
|
||||
production: env.NODE_ENV === 'production',
|
||||
staticDir: env.STATIC_DIR ?? 'dist',
|
||||
smtpUrl: env.SMTP_URL || undefined,
|
||||
mailFrom: env.MAIL_FROM ?? 'Open SafetyLog EHS <no-reply@localhost>',
|
||||
uploadDir: env.UPLOAD_DIR ?? './data/uploads',
|
||||
maxUploadBytes: Number(env.MAX_UPLOAD_MB ?? 10) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TYPE "public"."auth_token_purpose" AS ENUM('magic_link', 'password_reset');--> statement-breakpoint
|
||||
CREATE TABLE "auth_tokens" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"purpose" "auth_token_purpose" NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD COLUMN "reminder_sent_on" date;--> statement-breakpoint
|
||||
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "auth_tokens_user_idx" ON "auth_tokens" USING btree ("user_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,13 @@
|
||||
"when": 1791306770507,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1791309275942,
|
||||
"tag": "0001_workflow_and_email",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
||||
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
||||
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'])
|
||||
|
||||
const id = () => uuid().primaryKey().defaultRandom()
|
||||
const timestamptz = () => timestamp({ withTimezone: true })
|
||||
@@ -62,6 +63,23 @@ export const sessions = pgTable(
|
||||
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||
)
|
||||
|
||||
/** Single-use links sent by email (magic sign-in, password reset/invite). */
|
||||
export const authTokens = pgTable(
|
||||
'auth_tokens',
|
||||
{
|
||||
/** SHA-256 of the token sent in the link. */
|
||||
id: text().primaryKey(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
purpose: authTokenPurposeEnum().notNull(),
|
||||
expiresAt: timestamptz().notNull(),
|
||||
usedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('auth_tokens_user_idx').on(t.userId)],
|
||||
)
|
||||
|
||||
export const sites = pgTable(
|
||||
'sites',
|
||||
{
|
||||
@@ -230,6 +248,8 @@ export const correctiveActions = pgTable(
|
||||
completedAt: timestamptz(),
|
||||
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
verifiedAt: timestamptz(),
|
||||
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
|
||||
reminderSentOn: date({ mode: 'string' }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
|
||||
+18
-3
@@ -2,7 +2,7 @@
|
||||
import { hashPassword } from '../auth/password.ts'
|
||||
import { loadConfig } from '../config.ts'
|
||||
import { openDatabase } from './client.ts'
|
||||
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
|
||||
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
|
||||
|
||||
const DEMO_PASSWORD = 'safetylog-demo'
|
||||
|
||||
@@ -49,8 +49,10 @@ try {
|
||||
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
|
||||
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
|
||||
] as const
|
||||
await tx.insert(incidents).values(
|
||||
demo.map((item, index) => ({
|
||||
const created = await tx
|
||||
.insert(incidents)
|
||||
.values(
|
||||
demo.map((item, index) => ({
|
||||
organizationId: org.id,
|
||||
number: index + 1,
|
||||
title: item.title,
|
||||
@@ -58,10 +60,23 @@ try {
|
||||
severity: item.severity,
|
||||
status: item.status,
|
||||
occurredAt: daysAgo(item.days),
|
||||
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
|
||||
...areaIds[item.building],
|
||||
locationDetail: item.locationDetail,
|
||||
reporterId: person(item.reporter),
|
||||
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
|
||||
})),
|
||||
)
|
||||
.returning()
|
||||
await tx.insert(auditLog).values(
|
||||
created.map((incident) => ({
|
||||
organizationId: org.id,
|
||||
actorId: incident.reporterId,
|
||||
entityType: 'incident',
|
||||
entityId: incident.id,
|
||||
action: 'create',
|
||||
changes: { number: incident.number, title: incident.title },
|
||||
createdAt: incident.createdAt,
|
||||
})),
|
||||
)
|
||||
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
|
||||
|
||||
+12
-1
@@ -5,10 +5,19 @@ import { join } from 'node:path'
|
||||
import { createApp } from './app.ts'
|
||||
import { loadConfig } from './config.ts'
|
||||
import { openDatabase } from './db/client.ts'
|
||||
import { createMailer } from './mail.ts'
|
||||
import { Notifier } from './notifications.ts'
|
||||
import { FileStorage } from './storage.ts'
|
||||
|
||||
const config = loadConfig()
|
||||
const database = await openDatabase(config.databaseUrl)
|
||||
const app = createApp({ db: database.db, config })
|
||||
const notifier = new Notifier(database.db, createMailer(config), config)
|
||||
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(config.uploadDir) })
|
||||
|
||||
// Hourly check for corrective actions that are due or overdue; each gets at most one reminder a day.
|
||||
const sendReminders = () => notifier.sendDueReminders().catch((error) => console.error('[reminders] failed:', error))
|
||||
const reminderTimer = setInterval(sendReminders, 60 * 60 * 1000)
|
||||
setTimeout(sendReminders, 30_000).unref()
|
||||
|
||||
// In production the server also serves the built web app.
|
||||
if (existsSync(join(config.staticDir, 'index.html'))) {
|
||||
@@ -30,7 +39,9 @@ const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
clearInterval(reminderTimer)
|
||||
server.close(async () => {
|
||||
await notifier.flush()
|
||||
await database.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toCsv } from './csv.ts'
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('quotes commas, quotes and newlines', () => {
|
||||
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('a,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||
})
|
||||
|
||||
it('neutralises formula prefixes and blanks nulls', () => {
|
||||
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
type Cell = string | number | null | undefined
|
||||
|
||||
/**
|
||||
* RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a
|
||||
* formula are prefixed with an apostrophe (CSV injection).
|
||||
*/
|
||||
export function toCsv(header: string[], rows: Cell[][]): string {
|
||||
const cell = (value: Cell) => {
|
||||
let text = value === null || value === undefined ? '' : String(value)
|
||||
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||
}
|
||||
return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** ISO string for a Date, keeping nulls. */
|
||||
export const iso = <T extends Date | null>(date: T): T extends Date ? string : string | null => (date ? date.toISOString() : null) as never
|
||||
|
||||
/** Calendar date in the server's time zone (set TZ to the plant's zone), e.g. `2026-10-06`. */
|
||||
export function localDate(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function addDays(isoDate: string, days: number): string {
|
||||
const date = new Date(`${isoDate}T12:00:00`)
|
||||
date.setDate(date.getDate() + days)
|
||||
return localDate(date)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
|
||||
const bytes = (...values: (number | string)[]) => new Uint8Array(values.flatMap((v) => (typeof v === 'string' ? [...v].map((ch) => ch.charCodeAt(0)) : [v])))
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('recognises supported formats', () => {
|
||||
expect(detectFileType(bytes(0xff, 0xd8, 0xff, 0xe0))?.contentType).toBe('image/jpeg')
|
||||
expect(detectFileType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))?.contentType).toBe('image/webp')
|
||||
expect(detectFileType(bytes(0, 0, 0, 24, 'ftypheic'))?.contentType).toBe('image/heic')
|
||||
expect(detectFileType(bytes('%PDF-1.7'))?.contentType).toBe('application/pdf')
|
||||
})
|
||||
|
||||
it('rejects everything else', () => {
|
||||
expect(detectFileType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBeNull()
|
||||
expect(detectFileType(bytes('PK', 3, 4))).toBeNull()
|
||||
expect(detectFileType(new Uint8Array())).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface DetectedType {
|
||||
contentType: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
const startsWith = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((byte, i) => bytes[offset + i] === byte)
|
||||
const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to))
|
||||
|
||||
/**
|
||||
* Identifies photos and PDFs from their first bytes, ignoring the client's claimed type and file name.
|
||||
* Returns null for anything else, so only these formats are ever stored and served.
|
||||
*/
|
||||
export function detectFileType(bytes: Uint8Array): DetectedType | null {
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { contentType: 'image/jpeg', extension: 'jpg' }
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { contentType: 'image/png', extension: 'png' }
|
||||
if (ascii(bytes, 0, 6) === 'GIF87a' || ascii(bytes, 0, 6) === 'GIF89a') return { contentType: 'image/gif', extension: 'gif' }
|
||||
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return { contentType: 'image/webp', extension: 'webp' }
|
||||
if (ascii(bytes, 4, 8) === 'ftyp' && ['heic', 'heix', 'mif1', 'msf1'].includes(ascii(bytes, 8, 12))) return { contentType: 'image/heic', extension: 'heic' }
|
||||
if (ascii(bytes, 0, 5) === '%PDF-') return { contentType: 'application/pdf', extension: 'pdf' }
|
||||
return null
|
||||
}
|
||||
+5
-1
@@ -5,12 +5,16 @@ import type { ZodType } from 'zod'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import type { Notifier } from '../notifications.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
db: Db
|
||||
config: Config
|
||||
user: SessionUser | null
|
||||
notifier: Notifier
|
||||
storage: FileStorage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ export function validate<Target extends keyof ValidationTargets, Schema extends
|
||||
})
|
||||
}
|
||||
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 429, message: string) => new HTTPException(status, { message })
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message })
|
||||
|
||||
/** The signed-in user; use after `requireAuth`. */
|
||||
export function currentUser(c: Context<AppEnv>): SessionUser {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||
import { alias } from 'drizzle-orm/pg-core'
|
||||
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
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 { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
const reporters = alias(users, 'reporter')
|
||||
|
||||
export function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||
}
|
||||
|
||||
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||
|
||||
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
reference: incidentReference(incident.number),
|
||||
title: incident.title,
|
||||
type: incident.type,
|
||||
severity: incident.severity,
|
||||
status: incident.status,
|
||||
occurredAt: iso(incident.occurredAt),
|
||||
reportedAt: iso(incident.createdAt),
|
||||
updatedAt: iso(incident.updatedAt),
|
||||
closedAt: iso(incident.closedAt),
|
||||
site: { id: incident.siteId, name: siteName },
|
||||
area: incident.areaId ? { id: incident.areaId, name: areaName ?? '' } : null,
|
||||
locationDetail: incident.locationDetail,
|
||||
description: incident.description,
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisors and above see every incident in their organization. Reporters see incidents they
|
||||
* reported, plus any where they are assigned a corrective action.
|
||||
*/
|
||||
export function visibleTo(db: Db, user: SessionUser): SQL | undefined {
|
||||
const inOrg = eq(incidents.organizationId, user.organizationId)
|
||||
if (hasRole(user.role, 'supervisor')) return inOrg
|
||||
const assigned = db
|
||||
.select({ id: correctiveActions.id })
|
||||
.from(correctiveActions)
|
||||
.where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id)))
|
||||
return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned)))
|
||||
}
|
||||
|
||||
export async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||
const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number)))
|
||||
if (!row) throw fail(404, 'Incident not found')
|
||||
return row
|
||||
}
|
||||
|
||||
const assignees = alias(users, 'assignee')
|
||||
const verifiers = alias(users, 'verifier')
|
||||
|
||||
export function selectActions(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
action: correctiveActions,
|
||||
incidentNumber: incidents.number,
|
||||
incidentTitle: incidents.title,
|
||||
assigneeName: assignees.name,
|
||||
verifierName: verifiers.name,
|
||||
})
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId))
|
||||
.leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById))
|
||||
}
|
||||
|
||||
type ActionRow = Awaited<ReturnType<ReturnType<typeof selectActions>['execute']>>[number]
|
||||
|
||||
export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress'
|
||||
|
||||
export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction {
|
||||
return {
|
||||
id: action.id,
|
||||
incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle },
|
||||
rootCauseId: action.rootCauseId,
|
||||
kind: action.kind,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null,
|
||||
dueDate: action.dueDate,
|
||||
status: action.status,
|
||||
overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today,
|
||||
completedAt: iso(action.completedAt),
|
||||
verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null,
|
||||
verifiedAt: iso(action.verifiedAt),
|
||||
createdAt: iso(action.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment {
|
||||
return {
|
||||
id: row.id,
|
||||
fileName: row.fileName,
|
||||
contentType: row.contentType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null,
|
||||
createdAt: iso(row.createdAt),
|
||||
url: `/api/attachments/${row.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDetail(db: Db, user: SessionUser, number: number): Promise<IncidentDetail> {
|
||||
const row = await findIncident(db, user, number)
|
||||
const incidentId = row.incident.id
|
||||
const leads = alias(users, 'lead')
|
||||
|
||||
const [investigationRow] = await db
|
||||
.select({ investigation: investigations, leadName: leads.name })
|
||||
.from(investigations)
|
||||
.leftJoin(leads, eq(leads.id, investigations.leadId))
|
||||
.where(eq(investigations.incidentId, incidentId))
|
||||
|
||||
const causes = investigationRow
|
||||
? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt))
|
||||
: []
|
||||
const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt))
|
||||
const uploaders = alias(users, 'uploader')
|
||||
const files = await db
|
||||
.select({ file: attachments, uploaderName: uploaders.name })
|
||||
.from(attachments)
|
||||
.leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById))
|
||||
.where(eq(attachments.incidentId, incidentId))
|
||||
.orderBy(asc(attachments.createdAt))
|
||||
|
||||
const investigation: Investigation | null = investigationRow
|
||||
? {
|
||||
id: investigationRow.investigation.id,
|
||||
lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null,
|
||||
method: investigationRow.investigation.method,
|
||||
summary: investigationRow.investigation.summary,
|
||||
startedAt: iso(investigationRow.investigation.startedAt),
|
||||
completedAt: iso(investigationRow.investigation.completedAt),
|
||||
}
|
||||
: null
|
||||
const actions = actionRows.map((action) => toAction(action))
|
||||
const manage = hasRole(user.role, 'supervisor')
|
||||
|
||||
return {
|
||||
...toIncident(row),
|
||||
investigation,
|
||||
rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })),
|
||||
actions,
|
||||
attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)),
|
||||
transitions: availableTransitions(row.incident.status, user.role, {
|
||||
severity: row.incident.severity,
|
||||
rootCauseCount: causes.length,
|
||||
actionStatuses: actions.map((action) => action.status),
|
||||
}),
|
||||
can: { edit: manage, manageInvestigation: manage, manageActions: manage },
|
||||
}
|
||||
}
|
||||
|
||||
/** Records an event on an incident's timeline. */
|
||||
export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record<string, unknown>) {
|
||||
return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes })
|
||||
}
|
||||
|
||||
/** Throws 400 unless the user is active in the organization. */
|
||||
export async function assertActiveUser(db: Db, organizationId: string, userId: string) {
|
||||
const [user] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true)))
|
||||
if (!user) throw fail(400, 'Unknown or inactive user')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import type { Config } from './config.ts'
|
||||
|
||||
export interface Mail {
|
||||
to: string
|
||||
subject: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Mailer {
|
||||
send(mail: Mail): Promise<void>
|
||||
}
|
||||
|
||||
export function createMailer(config: Config): Mailer {
|
||||
if (config.smtpUrl) {
|
||||
const transport = nodemailer.createTransport(config.smtpUrl)
|
||||
return { send: async (mail) => void (await transport.sendMail({ from: config.mailFrom, ...mail })) }
|
||||
}
|
||||
if (config.production) {
|
||||
console.warn('[mail] SMTP_URL is not set: emails (sign-in links, password resets, notifications) will not be sent.')
|
||||
return { send: async (mail) => console.warn(`[mail] Not sent (no SMTP): "${mail.subject}" to ${mail.to}`) }
|
||||
}
|
||||
// Development: print the email so links can be clicked from the terminal.
|
||||
return { send: async (mail) => console.log(`\n[mail] To: ${mail.to}\n[mail] Subject: ${mail.subject}\n\n${mail.text}\n`) }
|
||||
}
|
||||
|
||||
/** Collects emails instead of sending them (tests). */
|
||||
export class MemoryMailer implements Mailer {
|
||||
sent: Mail[] = []
|
||||
|
||||
async send(mail: Mail) {
|
||||
this.sent.push(mail)
|
||||
}
|
||||
|
||||
/** Most recent email to `to`. */
|
||||
last(to: string): Mail | undefined {
|
||||
return this.sent.findLast((mail) => mail.to === to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-orm'
|
||||
import { incidentReference } from '../shared/format.ts'
|
||||
import type { Config } from './config.ts'
|
||||
import type { Db } from './db/client.ts'
|
||||
import { correctiveActions, incidents, users } from './db/schema.ts'
|
||||
import { addDays, localDate } from './lib/dates.ts'
|
||||
import type { Mailer } from './mail.ts'
|
||||
|
||||
const severityLabel = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } as const
|
||||
|
||||
/** Sends emails in the background so slow mail servers never delay or fail a request. */
|
||||
export class Notifier {
|
||||
private readonly pending = new Set<Promise<void>>()
|
||||
private readonly db: Db
|
||||
private readonly mailer: Mailer
|
||||
private readonly config: Config
|
||||
|
||||
constructor(db: Db, mailer: Mailer, config: Config) {
|
||||
this.db = db
|
||||
this.mailer = mailer
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/** Waits for queued emails (tests, shutdown). */
|
||||
async flush(): Promise<void> {
|
||||
await Promise.all([...this.pending])
|
||||
}
|
||||
|
||||
private dispatch(task: () => Promise<void>) {
|
||||
const run = task()
|
||||
.catch((error) => console.error('[notify] failed:', error))
|
||||
.finally(() => this.pending.delete(run))
|
||||
this.pending.add(run)
|
||||
}
|
||||
|
||||
private link(path: string) {
|
||||
return new URL(path, this.config.publicUrl).toString()
|
||||
}
|
||||
|
||||
magicLink(email: string, token: string) {
|
||||
this.dispatch(() =>
|
||||
this.mailer.send({
|
||||
to: email,
|
||||
subject: 'Your sign-in link',
|
||||
text: `Use this link to sign in to Open SafetyLog EHS. It works once and expires in 15 minutes.\n\n${this.link(`/auth/magic?token=${token}`)}\n\nIf you didn't ask for it, you can ignore this email.`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
passwordReset(email: string, token: string, invite: { organization: string; invitedBy: string } | null) {
|
||||
const link = this.link(`/auth/reset?token=${token}`)
|
||||
this.dispatch(() =>
|
||||
this.mailer.send(
|
||||
invite
|
||||
? {
|
||||
to: email,
|
||||
subject: `You've been invited to ${invite.organization} on Open SafetyLog EHS`,
|
||||
text: `${invite.invitedBy} added you to ${invite.organization}'s safety workspace.\n\nSet your password to get started (link valid for 7 days):\n\n${link}`,
|
||||
}
|
||||
: {
|
||||
to: email,
|
||||
subject: 'Reset your password',
|
||||
text: `Use this link to choose a new password. It works once and expires in 1 hour.\n\n${link}\n\nIf you didn't ask for it, you can ignore this email; your password hasn't changed.`,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Alerts safety managers and admins about high and critical reports. */
|
||||
incidentReported(incidentId: string) {
|
||||
this.dispatch(async () => {
|
||||
const [incident] = await this.db.select().from(incidents).where(eq(incidents.id, incidentId))
|
||||
if (!incident || (incident.severity !== 'high' && incident.severity !== 'critical')) return
|
||||
const recipients = await this.db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.organizationId, incident.organizationId),
|
||||
eq(users.active, true),
|
||||
inArray(users.role, ['safety_manager', 'admin']),
|
||||
incident.reporterId ? ne(users.id, incident.reporterId) : undefined,
|
||||
),
|
||||
)
|
||||
const reference = incidentReference(incident.number)
|
||||
for (const { email } of recipients) {
|
||||
await this.mailer.send({
|
||||
to: email,
|
||||
subject: `[${severityLabel[incident.severity]}] ${reference}: ${incident.title}`,
|
||||
text: `A ${severityLabel[incident.severity].toLowerCase()}-severity incident was reported.\n\n${reference}: ${incident.title}\nOccurred: ${incident.occurredAt.toLocaleString('en-US')}\n\n${this.link(`/incidents/${incident.number}`)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
actionAssigned(actionId: string, assignedBy: string) {
|
||||
this.dispatch(async () => {
|
||||
const [row] = await this.db
|
||||
.select({ action: correctiveActions, email: users.email, number: incidents.number, incidentTitle: incidents.title })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(eq(correctiveActions.id, actionId), eq(users.active, true)))
|
||||
if (!row) return
|
||||
const due = row.action.dueDate ? `Due: ${row.action.dueDate}\n` : ''
|
||||
await this.mailer.send({
|
||||
to: row.email,
|
||||
subject: `Action assigned: ${row.action.title}`,
|
||||
text: `${assignedBy} assigned you a corrective action on ${incidentReference(row.number)} (${row.incidentTitle}).\n\n${row.action.title}\n${due}\n${this.link(`/incidents/${row.number}`)}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emails each assignee a digest of open actions due tomorrow or earlier, at most once a day per action.
|
||||
* Returns how many emails were queued.
|
||||
*/
|
||||
async sendDueReminders(today = localDate()): Promise<number> {
|
||||
const rows = await this.db
|
||||
.select({ action: correctiveActions, email: users.email, number: incidents.number })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(correctiveActions.status, ['open', 'in_progress']),
|
||||
eq(users.active, true),
|
||||
isNotNull(correctiveActions.dueDate),
|
||||
lte(correctiveActions.dueDate, addDays(today, 1)),
|
||||
or(isNull(correctiveActions.reminderSentOn), lt(correctiveActions.reminderSentOn, today)),
|
||||
),
|
||||
)
|
||||
const byEmail = Map.groupBy(rows, (row) => row.email)
|
||||
for (const [email, items] of byEmail) {
|
||||
const lines = items.map(({ action, number }) => {
|
||||
const when = action.dueDate! < today ? `OVERDUE since ${action.dueDate}` : action.dueDate === today ? 'due today' : 'due tomorrow'
|
||||
return `- ${action.title} (${incidentReference(number)}), ${when}\n ${this.link(`/incidents/${number}`)}`
|
||||
})
|
||||
const overdue = items.filter(({ action }) => action.dueDate! < today).length
|
||||
this.dispatch(() =>
|
||||
this.mailer.send({
|
||||
to: email,
|
||||
subject: overdue ? `${overdue} overdue corrective action${overdue === 1 ? '' : 's'}` : 'Corrective actions due soon',
|
||||
text: `These corrective actions assigned to you need attention:\n\n${lines.join('\n')}\n\nAll your actions: ${this.link('/actions')}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (rows.length) {
|
||||
await this.db
|
||||
.update(correctiveActions)
|
||||
.set({ reminderSentOn: today })
|
||||
.where(inArray(correctiveActions.id, rows.map(({ action }) => action.id)))
|
||||
}
|
||||
return byEmail.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, asc, eq, inArray, lt, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { actionListQuery, updateActionSchema } from '../../shared/schemas.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import { correctiveActions, incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { localDate } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { assertActiveUser, auditIncident, selectActions, toAction } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
const OPEN = ['open', 'in_progress'] as const
|
||||
|
||||
export const actionRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', actionListQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { scope, filter } = c.req.valid('query')
|
||||
if (scope === 'all' && !hasRole(me.role, 'supervisor')) throw fail(403, 'You do not have permission to do that')
|
||||
const today = localDate()
|
||||
const rows = await selectActions(c.get('db'))
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.organizationId, me.organizationId),
|
||||
scope === 'mine' ? eq(correctiveActions.assigneeId, me.id) : undefined,
|
||||
filter === 'open' ? inArray(correctiveActions.status, OPEN) : undefined,
|
||||
filter === 'overdue' ? and(inArray(correctiveActions.status, OPEN), lt(correctiveActions.dueDate, today)) : undefined,
|
||||
filter === 'done' ? inArray(correctiveActions.status, ['done', 'verified']) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${correctiveActions.dueDate} asc nulls last`, asc(correctiveActions.createdAt))
|
||||
.limit(500)
|
||||
return c.json({ actions: rows.map((row) => toAction(row, today)) })
|
||||
})
|
||||
|
||||
.patch('/:id', requireAuth, validate('param', idParam), validate('json', updateActionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [row] = await selectActions(db).where(and(eq(correctiveActions.id, c.req.valid('param').id), eq(incidents.organizationId, me.organizationId)))
|
||||
if (!row) throw fail(404, 'Action not found')
|
||||
const { action } = row
|
||||
const input = c.req.valid('json')
|
||||
|
||||
const isManager = hasRole(me.role, 'supervisor')
|
||||
const isAssignee = action.assigneeId === me.id
|
||||
if (!isManager) {
|
||||
// Assignees can report progress on their own actions, nothing else.
|
||||
const onlyStatus = Object.keys(input).every((key) => key === 'status')
|
||||
if (!isAssignee || !onlyStatus || (input.status && !['open', 'in_progress', 'done'].includes(input.status))) {
|
||||
throw fail(403, 'You can only update the progress of actions assigned to you')
|
||||
}
|
||||
}
|
||||
if (input.status === 'verified' && !hasRole(me.role, 'safety_manager')) throw fail(403, 'Only safety managers can verify actions')
|
||||
if (action.status === 'verified' && input.status && input.status !== 'verified' && !hasRole(me.role, 'safety_manager')) {
|
||||
throw fail(403, 'Only safety managers can reopen a verified action')
|
||||
}
|
||||
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||
if (input.rootCauseId) {
|
||||
const [cause] = await db
|
||||
.select({ id: rootCauses.id })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, action.incidentId)))
|
||||
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const status = input.status ?? action.status
|
||||
const update = {
|
||||
...input,
|
||||
completedAt: status === 'done' || status === 'verified' ? (action.completedAt ?? now) : null,
|
||||
verifiedAt: status === 'verified' ? (action.verifiedAt ?? now) : null,
|
||||
verifiedById: status === 'verified' ? (action.verifiedById ?? me.id) : null,
|
||||
// A new due date deserves a fresh reminder
|
||||
reminderSentOn: input.dueDate !== undefined && input.dueDate !== action.dueDate ? null : undefined,
|
||||
}
|
||||
const changes = diff(action, update)
|
||||
delete changes.reminderSentOn
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(correctiveActions).set(update).where(eq(correctiveActions.id, action.id))
|
||||
await auditIncident(db, me, me.organizationId, action.incidentId, 'action.update', { actionId: action.id, title: action.title, ...changes })
|
||||
if (input.assigneeId && input.assigneeId !== action.assigneeId && input.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||
}
|
||||
const [updated] = await selectActions(db).where(eq(correctiveActions.id, action.id))
|
||||
return c.json({ action: toAction(updated) })
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let number: number
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||
const form = (bytes: Uint8Array<ArrayBuffer>, name: string, type = 'image/png') => {
|
||||
const data = new FormData()
|
||||
data.append('file', new File([bytes], name, { type }))
|
||||
return data
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
const site = await ctx.createSite()
|
||||
reporter = await ctx.createUser('reporter')
|
||||
const res = await reporter.post('/api/incidents', { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: hoursAgo(1), siteId: site.id })
|
||||
number = res.body.incident.number
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('attachments', () => {
|
||||
it('stores a photo and serves it back safely', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'spill.png'))
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body.attachment).toMatchObject({ fileName: 'spill.png', contentType: 'image/png', sizeBytes: PNG.byteLength })
|
||||
|
||||
const file = await reporter.get(res.body.attachment.url)
|
||||
expect(file.status).toBe(200)
|
||||
expect(file.headers.get('content-type')).toBe('image/png')
|
||||
expect(file.headers.get('content-security-policy')).toContain('sandbox')
|
||||
expect(new Uint8Array(file.body)).toEqual(PNG)
|
||||
|
||||
const detail = await reporter.get(`/api/incidents/${number}`)
|
||||
expect(detail.body.incident.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('identifies files by content, not by name or claimed type', async () => {
|
||||
const html = new TextEncoder().encode('<script>alert(1)</script>')
|
||||
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(html, 'photo.png', 'image/png'))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects files over the size limit', async () => {
|
||||
const big = new Uint8Array(ctx.config.maxUploadBytes + 1)
|
||||
big.set(PNG)
|
||||
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(big, 'big.png'))).status).toBe(413)
|
||||
})
|
||||
|
||||
it('hides files from people who cannot see the incident', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'second.png'))
|
||||
const outsider = await ctx.createUser('reporter', 'outsider@example.com')
|
||||
expect((await outsider.get(res.body.attachment.url)).status).toBe(404)
|
||||
expect((await outsider.delete(res.body.attachment.url)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('lets the uploader remove their file', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'remove-me.png'))
|
||||
expect((await reporter.delete(res.body.attachment.url)).status).toBe(204)
|
||||
expect((await reporter.get(res.body.attachment.url)).status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload origin check', () => {
|
||||
it('rejects uploads posted from another site', async () => {
|
||||
const res = await ctx.app.request(`/api/incidents/${number}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: reporter.cookie!, origin: 'http://evil.example' },
|
||||
body: form(PNG, 'x.png'),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { attachments, incidents } from '../db/schema.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident, visibleTo } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function findAttachment(db: Db, user: SessionUser, id: string) {
|
||||
const [row] = await db
|
||||
.select({ file: attachments })
|
||||
.from(attachments)
|
||||
.innerJoin(incidents, eq(incidents.id, attachments.incidentId))
|
||||
.where(and(eq(attachments.id, id), visibleTo(db, user)))
|
||||
if (!row) throw fail(404, 'Attachment not found')
|
||||
return row.file
|
||||
}
|
||||
|
||||
export const attachmentRoutes = new Hono<AppEnv>()
|
||||
.get('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||
const file = await findAttachment(c.get('db'), currentUser(c), c.req.valid('param').id)
|
||||
const data = await c.get('storage').read(file.storageKey)
|
||||
const disposition = file.contentType.startsWith('image/') ? 'inline' : 'attachment'
|
||||
return c.body(new Uint8Array(data), 200, {
|
||||
'Content-Type': file.contentType,
|
||||
'Content-Length': String(data.byteLength),
|
||||
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||
// Uploaded content never runs scripts, even if a file slipped past type detection
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
})
|
||||
})
|
||||
|
||||
.delete('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const file = await findAttachment(db, me, c.req.valid('param').id)
|
||||
if (file.uploadedById !== me.id && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only the uploader or a supervisor can remove this file')
|
||||
await db.delete(attachments).where(eq(attachments.id, file.id))
|
||||
await c.get('storage').remove(file.storageKey)
|
||||
await auditIncident(db, me, me.organizationId, file.incidentId, 'attachment.remove', { fileName: file.fileName })
|
||||
return c.body(null, 204)
|
||||
})
|
||||
+55
-1
@@ -1,17 +1,29 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { changePasswordSchema, loginSchema } from '../../shared/schemas.ts'
|
||||
import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts'
|
||||
import { endSession, requireAuth, startSession } from '../auth/middleware.ts'
|
||||
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
|
||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||
import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { organizations, users } from '../db/schema.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { publicUser } from './users.ts'
|
||||
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000
|
||||
const RESET_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
async function activeUserByEmail(db: Db, email: string) {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email))
|
||||
return user?.active ? user : null
|
||||
}
|
||||
|
||||
export function authRoutes() {
|
||||
// 10 failed-or-successful attempts per email per 15 minutes
|
||||
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
|
||||
// Emailed links: 5 per email per 15 minutes
|
||||
const emailLimiter = new RateLimiter(5, 15 * 60 * 1000)
|
||||
|
||||
return new Hono<AppEnv>()
|
||||
.post('/login', validate('json', loginSchema), async (c) => {
|
||||
@@ -30,6 +42,48 @@ export function authRoutes() {
|
||||
return c.json({ user: result })
|
||||
})
|
||||
|
||||
// Always 204, so the response never reveals whether an account exists.
|
||||
.post('/magic-link', validate('json', emailOnlySchema), async (c) => {
|
||||
const { email } = c.req.valid('json')
|
||||
if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||
const db = c.get('db')
|
||||
const user = await activeUserByEmail(db, email)
|
||||
if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS))
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
// POST rather than GET so email link scanners can't use up the token.
|
||||
.post('/magic-link/verify', validate('json', tokenSchema), async (c) => {
|
||||
const db = c.get('db')
|
||||
const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link')
|
||||
if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.')
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||
await startSession(c, userId)
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||
return c.json({ user })
|
||||
})
|
||||
|
||||
.post('/password-reset', validate('json', emailOnlySchema), async (c) => {
|
||||
const { email } = c.req.valid('json')
|
||||
if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||
const db = c.get('db')
|
||||
const user = await activeUserByEmail(db, email)
|
||||
if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null)
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
.post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => {
|
||||
const { token, newPassword } = c.req.valid('json')
|
||||
const db = c.get('db')
|
||||
const userId = await consumeAuthToken(db, token, 'password_reset')
|
||||
if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.')
|
||||
await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||
await deleteUserSessions(db, userId)
|
||||
await startSession(c, userId)
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||
return c.json({ user })
|
||||
})
|
||||
|
||||
.post('/logout', async (c) => {
|
||||
await endSession(c)
|
||||
return c.body(null, 204)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, tokenFrom } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
await ctx.createUser('supervisor', 'linked@example.com')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
const lastToken = async (email: string) => {
|
||||
await ctx.notifier.flush()
|
||||
return tokenFrom(ctx.mailer.last(email)?.text)
|
||||
}
|
||||
|
||||
describe('magic links', () => {
|
||||
it('signs in once with an emailed link', async () => {
|
||||
const client = ctx.anonymous()
|
||||
expect((await client.post('/api/auth/magic-link', { email: 'Linked@Example.com' })).status).toBe(204)
|
||||
const token = await lastToken('linked@example.com')
|
||||
expect(ctx.mailer.last('linked@example.com')?.text).toContain(`http://safety.test/auth/magic?token=${token}`)
|
||||
|
||||
const res = await client.post('/api/auth/magic-link/verify', { token })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.user.email).toBe('linked@example.com')
|
||||
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||
|
||||
expect((await ctx.anonymous().post('/api/auth/magic-link/verify', { token })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers the same for unknown emails and sends nothing', async () => {
|
||||
const before = ctx.mailer.sent.length
|
||||
expect((await ctx.anonymous().post('/api/auth/magic-link', { email: 'nobody@example.com' })).status).toBe(204)
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.sent.length).toBe(before)
|
||||
})
|
||||
|
||||
it("doesn't accept a magic link token as a password reset", async () => {
|
||||
await ctx.anonymous().post('/api/auth/magic-link', { email: 'linked@example.com' })
|
||||
const token = await lastToken('linked@example.com')
|
||||
expect((await ctx.anonymous().post('/api/auth/password-reset/confirm', { token, newPassword: 'a whole new password' })).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset', () => {
|
||||
it('sets a new password and signs out other sessions', async () => {
|
||||
const user = await ctx.createUser('reporter', 'forgetful@example.com')
|
||||
await ctx.anonymous().post('/api/auth/password-reset', { email: 'forgetful@example.com' })
|
||||
const token = await lastToken('forgetful@example.com')
|
||||
|
||||
const client = ctx.anonymous()
|
||||
const res = await client.post('/api/auth/password-reset/confirm', { token, newPassword: 'remembered at last' })
|
||||
expect(res.status).toBe(200)
|
||||
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||
expect((await user.get('/api/auth/me')).status).toBe(401)
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'forgetful@example.com', password: 'remembered at last' })).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invitations', () => {
|
||||
it('emails new users without a password a link to set one', async () => {
|
||||
const res = await ctx.admin.post('/api/users', { name: 'New Starter', email: 'starter@example.com', role: 'reporter' })
|
||||
expect(res.body).toMatchObject({ invited: true })
|
||||
await ctx.notifier.flush()
|
||||
const mail = ctx.mailer.last('starter@example.com')
|
||||
expect(mail?.subject).toBe("You've been invited to Northstar Co. on Open SafetyLog EHS")
|
||||
|
||||
// Can't sign in until the password is set
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'starter@example.com', password: 'anything at all' })).status).toBe(401)
|
||||
const client = ctx.anonymous()
|
||||
expect((await client.post('/api/auth/password-reset/confirm', { token: tokenFrom(mail?.text), newPassword: 'my first password' })).status).toBe(200)
|
||||
expect((await client.get('/api/auth/me')).body.user.email).toBe('starter@example.com')
|
||||
})
|
||||
|
||||
it('lets admins set a password directly when email is not available', async () => {
|
||||
const created = await ctx.admin.post('/api/users', { name: 'No Email', email: 'offline@example.com', role: 'reporter' })
|
||||
await ctx.admin.patch(`/api/users/${created.body.user.id}`, { password: 'set by the admin' })
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'offline@example.com', password: 'set by the admin' })).status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -108,27 +108,62 @@ describe('listing', () => {
|
||||
})
|
||||
|
||||
describe('updating', () => {
|
||||
it('lets supervisors update and close incidents, recording what changed', async () => {
|
||||
it('lets supervisors edit details, recording what changed', async () => {
|
||||
const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
|
||||
const number = created.body.incident.number
|
||||
expect((await reporter.patch(`/api/incidents/${number}`, { status: 'closed' })).status).toBe(403)
|
||||
expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403)
|
||||
|
||||
const closed = await supervisor.patch(`/api/incidents/${number}`, { status: 'closed', severity: 'low' })
|
||||
expect(closed.status).toBe(200)
|
||||
expect(closed.body.incident.status).toBe('closed')
|
||||
expect(closed.body.incident.closedAt).not.toBeNull()
|
||||
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
|
||||
expect(edited.status).toBe(200)
|
||||
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
|
||||
|
||||
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
|
||||
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
|
||||
expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
|
||||
})
|
||||
|
||||
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
|
||||
expect(reopened.body.incident.closedAt).toBeNull()
|
||||
it('does not change status through edits', async () => {
|
||||
const created = await reporter.post('/api/incidents', report())
|
||||
const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' })
|
||||
expect(res.body.incident.status).toBe('reported')
|
||||
})
|
||||
|
||||
it('clears the area when moving an incident to another site', async () => {
|
||||
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
|
||||
const south = await ctx.createSite('West Campus')
|
||||
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id })
|
||||
expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null })
|
||||
const west = await ctx.createSite('West Campus')
|
||||
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
|
||||
expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('notifications', () => {
|
||||
it('alerts safety managers about high-severity reports, not low ones', async () => {
|
||||
const manager = await ctx.createUser('safety_manager', 'alerts@example.com')
|
||||
await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' }))
|
||||
await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' }))
|
||||
await ctx.notifier.flush()
|
||||
const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email)
|
||||
expect(mails).toHaveLength(1)
|
||||
expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/)
|
||||
expect(mails[0].text).toContain('http://safety.test/incidents/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('export', () => {
|
||||
it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => {
|
||||
await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' }))
|
||||
const res = await supervisor.get('/api/incidents/export?type=environmental')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/csv')
|
||||
const csv = res.body.toString('utf8')
|
||||
const lines = csv.trimEnd().split('\r\n')
|
||||
expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/)
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`)
|
||||
})
|
||||
|
||||
it('only exports what the user can see', async () => {
|
||||
const other = await ctx.createUser('reporter', 'csv-reporter@example.com')
|
||||
const res = await other.get('/api/incidents/export')
|
||||
expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+271
-104
@@ -1,72 +1,38 @@
|
||||
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { z } from 'zod'
|
||||
import type { Comment, TimelineEntry } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import {
|
||||
commentSchema,
|
||||
createActionSchema,
|
||||
createIncidentSchema,
|
||||
incidentListQuery,
|
||||
investigationSchema,
|
||||
rootCauseSchema,
|
||||
transitionSchema,
|
||||
updateIncidentSchema,
|
||||
type IncidentListQuery,
|
||||
} from '../../shared/schemas.ts'
|
||||
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, incidentCounters, incidents, sites, users } from '../db/schema.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { toCsv } from '../lib/csv.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'
|
||||
|
||||
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 columns = {
|
||||
incident: incidents,
|
||||
siteName: sites.name,
|
||||
areaName: areas.name,
|
||||
reporterName: users.name,
|
||||
}
|
||||
|
||||
type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null }
|
||||
|
||||
function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
reference: incidentReference(incident.number),
|
||||
title: incident.title,
|
||||
type: incident.type,
|
||||
severity: incident.severity,
|
||||
status: incident.status,
|
||||
occurredAt: incident.occurredAt,
|
||||
reportedAt: incident.createdAt,
|
||||
updatedAt: incident.updatedAt,
|
||||
closedAt: incident.closedAt,
|
||||
site: { id: incident.siteId, name: siteName },
|
||||
area: incident.areaId ? { id: incident.areaId, name: areaName } : null,
|
||||
locationDetail: incident.locationDetail,
|
||||
description: incident.description,
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select(columns)
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(users, eq(users.id, incidents.reporterId))
|
||||
}
|
||||
|
||||
/** Reporters only see what they reported; supervisors and above see everything in the organization. */
|
||||
function visibleTo(user: SessionUser): SQL | undefined {
|
||||
return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id))
|
||||
}
|
||||
|
||||
async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||
const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number)))
|
||||
if (!row) throw fail(404, 'Incident not found')
|
||||
return row
|
||||
}
|
||||
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
|
||||
@@ -91,39 +57,78 @@ function parseOccurredAt(value: string): Date {
|
||||
|
||||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||
let search: SQL | undefined
|
||||
if (q) {
|
||||
const pattern = `%${escapeLike(q)}%`
|
||||
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
|
||||
search = or(
|
||||
ilike(incidents.title, pattern),
|
||||
ilike(incidents.description, pattern),
|
||||
ilike(incidents.locationDetail, pattern),
|
||||
number ? eq(incidents.number, Number(number)) : undefined,
|
||||
)
|
||||
}
|
||||
return and(
|
||||
visibleTo(db, user),
|
||||
search,
|
||||
type ? eq(incidents.type, type) : undefined,
|
||||
severity ? eq(incidents.severity, severity) : undefined,
|
||||
status ? eq(incidents.status, status) : undefined,
|
||||
siteId ? eq(incidents.siteId, siteId) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates the incident's investigation if it doesn't exist yet and returns its id. */
|
||||
async function ensureInvestigation(db: Db, incidentId: string): Promise<string> {
|
||||
await db.insert(investigations).values({ incidentId }).onConflictDoNothing({ target: investigations.incidentId })
|
||||
const [row] = await db.select({ id: investigations.id }).from(investigations).where(eq(investigations.incidentId, incidentId))
|
||||
return row.id
|
||||
}
|
||||
|
||||
export const incidentRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query')
|
||||
const { page, pageSize, ...filters } = c.req.valid('query')
|
||||
const db = c.get('db')
|
||||
|
||||
let search: SQL | undefined
|
||||
if (q) {
|
||||
const pattern = `%${escapeLike(q)}%`
|
||||
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
|
||||
search = or(
|
||||
ilike(incidents.title, pattern),
|
||||
ilike(incidents.description, pattern),
|
||||
ilike(incidents.locationDetail, pattern),
|
||||
number ? eq(incidents.number, Number(number)) : undefined,
|
||||
)
|
||||
}
|
||||
const where = and(
|
||||
visibleTo(me),
|
||||
search,
|
||||
type ? eq(incidents.type, type) : undefined,
|
||||
severity ? eq(incidents.severity, severity) : undefined,
|
||||
status ? eq(incidents.status, status) : undefined,
|
||||
siteId ? eq(incidents.siteId, siteId) : undefined,
|
||||
)
|
||||
|
||||
const where = listFilter(db, me, filters)
|
||||
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
|
||||
const rows = await selectIncidents(db)
|
||||
.where(where)
|
||||
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize)
|
||||
return c.json({ items: rows.map(toDto), page, pageSize, total })
|
||||
return c.json({ items: rows.map(toIncident), page, pageSize, total })
|
||||
})
|
||||
|
||||
.get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const rows = await selectIncidents(db)
|
||||
.where(listFilter(db, me, c.req.valid('query')))
|
||||
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||
.limit(EXPORT_LIMIT)
|
||||
const csv = toCsv(
|
||||
['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'],
|
||||
rows.map(toIncident).map((i) => [
|
||||
i.reference,
|
||||
i.title,
|
||||
i.type,
|
||||
i.severity,
|
||||
i.status,
|
||||
i.occurredAt,
|
||||
i.reportedAt,
|
||||
i.closedAt,
|
||||
i.site.name,
|
||||
i.area?.name,
|
||||
i.locationDetail,
|
||||
i.anonymous ? 'Anonymous' : i.reporter?.name,
|
||||
i.description,
|
||||
i.immediateAction,
|
||||
]),
|
||||
)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="incidents-${date}.csv"` })
|
||||
})
|
||||
|
||||
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
|
||||
@@ -133,7 +138,7 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
const number = await db.transaction(async (tx) => {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||
const [{ lastNumber }] = await tx
|
||||
.insert(incidentCounters)
|
||||
@@ -143,25 +148,23 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
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 })
|
||||
await audit(tx, {
|
||||
organizationId: me.organizationId,
|
||||
actorId: anonymous ? null : me.id,
|
||||
entityType: 'incident',
|
||||
entityId: incident.id,
|
||||
action: 'create',
|
||||
changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity },
|
||||
.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 lastNumber
|
||||
return incident
|
||||
})
|
||||
|
||||
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
|
||||
return c.json({ incident: toDto(row) }, 201)
|
||||
c.get('notifier').incidentReported(created.id)
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
|
||||
return c.json({ incident: toIncident(row) }, 201)
|
||||
})
|
||||
|
||||
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number)
|
||||
return c.json({ incident: toDto(row) })
|
||||
return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) })
|
||||
})
|
||||
|
||||
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
|
||||
@@ -175,17 +178,181 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area
|
||||
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
|
||||
|
||||
const update = {
|
||||
...input,
|
||||
areaId,
|
||||
occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined,
|
||||
closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null,
|
||||
}
|
||||
const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined }
|
||||
const changes = diff(incident, update)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes })
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
|
||||
}
|
||||
const row = await findIncident(db, me, incident.number)
|
||||
return c.json({ incident: toDto(row) })
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||
})
|
||||
|
||||
.post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { to, note } = c.req.valid('json')
|
||||
const detail = await loadDetail(db, me, c.req.valid('param').number)
|
||||
|
||||
const rule = transitions.find((t) => t.from === detail.status && t.to === to)
|
||||
if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`)
|
||||
if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that')
|
||||
const blocker = transitionBlocker(rule, {
|
||||
severity: detail.severity,
|
||||
rootCauseCount: detail.rootCauses.length,
|
||||
actionStatuses: detail.actions.map((action) => action.status),
|
||||
})
|
||||
if (blocker) throw fail(409, blocker)
|
||||
|
||||
// Conditional update guards against two people moving the same incident at once.
|
||||
const moved = await db
|
||||
.update(incidents)
|
||||
.set({ status: to, closedAt: to === 'closed' ? new Date() : null })
|
||||
.where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status)))
|
||||
.returning({ id: incidents.id })
|
||||
if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.')
|
||||
|
||||
await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) })
|
||||
if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note })
|
||||
return c.json({ incident: await loadDetail(db, me, detail.number) })
|
||||
})
|
||||
|
||||
.get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
|
||||
const rows = await db
|
||||
.select({ comment: comments, authorName: users.name })
|
||||
.from(comments)
|
||||
.leftJoin(users, eq(users.id, comments.authorId))
|
||||
.where(eq(comments.incidentId, incident.id))
|
||||
.orderBy(asc(comments.createdAt))
|
||||
const result: Comment[] = rows.map(({ comment, authorName }) => ({
|
||||
id: comment.id,
|
||||
author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null,
|
||||
body: comment.body,
|
||||
createdAt: iso(comment.createdAt),
|
||||
}))
|
||||
return c.json({ comments: result })
|
||||
})
|
||||
|
||||
.post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning()
|
||||
const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) }
|
||||
return c.json({ comment: result }, 201)
|
||||
})
|
||||
|
||||
.get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const rows = await db
|
||||
.select({ entry: auditLog, actorName: users.name })
|
||||
.from(auditLog)
|
||||
.leftJoin(users, eq(users.id, auditLog.actorId))
|
||||
.where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id)))
|
||||
.orderBy(asc(auditLog.id))
|
||||
const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({
|
||||
id: entry.id,
|
||||
at: iso(entry.createdAt),
|
||||
actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null,
|
||||
action: entry.action,
|
||||
changes: (entry.changes as Record<string, unknown> | null) ?? null,
|
||||
}))
|
||||
return c.json({ timeline })
|
||||
})
|
||||
|
||||
.put('/:number/investigation', requireRole('supervisor'), validate('param', numberParam), validate('json', investigationSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const { completed, ...input } = c.req.valid('json')
|
||||
if (input.leadId) await assertActiveUser(db, me.organizationId, input.leadId)
|
||||
|
||||
const investigationId = await ensureInvestigation(db, incident.id)
|
||||
const [current] = await db.select().from(investigations).where(eq(investigations.id, investigationId))
|
||||
const update = { ...input, completedAt: completed === undefined ? undefined : completed ? (current.completedAt ?? new Date()) : null }
|
||||
const changes = diff(current, update)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(investigations).set(update).where(eq(investigations.id, investigationId))
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'investigation.update', changes)
|
||||
}
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||
})
|
||||
|
||||
.post('/:number/root-causes', requireRole('supervisor'), validate('param', numberParam), validate('json', rootCauseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const input = c.req.valid('json')
|
||||
const investigationId = await ensureInvestigation(db, incident.id)
|
||||
await db.insert(rootCauses).values({ ...input, investigationId })
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'root_cause.add', { category: input.category, description: input.description })
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||
})
|
||||
|
||||
.post('/:number/actions', requireRole('supervisor'), validate('param', numberParam), validate('json', createActionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
if (incident.status === 'closed') throw fail(409, 'Reopen the incident before adding actions')
|
||||
const input = c.req.valid('json')
|
||||
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||
if (input.rootCauseId) {
|
||||
const [cause] = await db
|
||||
.select({ id: rootCauses.id })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, incident.id)))
|
||||
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||
}
|
||||
|
||||
const [action] = await db
|
||||
.insert(correctiveActions)
|
||||
.values({ ...input, incidentId: incident.id })
|
||||
.returning()
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'action.add', { actionId: action.id, title: action.title, assigneeId: action.assigneeId, dueDate: action.dueDate })
|
||||
if (action.assigneeId && action.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||
})
|
||||
|
||||
.post(
|
||||
'/:number/attachments',
|
||||
requireAuth,
|
||||
validate('param', numberParam),
|
||||
// Reject oversized bodies before reading them into memory
|
||||
async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes + 64 * 1024, onError: (c) => c.json({ error: 'File is too large' }, 413) })(c, next),
|
||||
async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const config = c.get('config')
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { updateRootCauseSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident, loadDetail } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function findRootCause(db: Db, user: SessionUser, id: string) {
|
||||
const [row] = await db
|
||||
.select({ cause: rootCauses, incidentId: incidents.id, number: incidents.number })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
|
||||
.where(and(eq(rootCauses.id, id), eq(incidents.organizationId, user.organizationId)))
|
||||
if (!row) throw fail(404, 'Root cause not found')
|
||||
return row
|
||||
}
|
||||
|
||||
export const rootCauseRoutes = new Hono<AppEnv>()
|
||||
.patch('/:id', requireRole('supervisor'), validate('param', idParam), validate('json', updateRootCauseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||
const input = c.req.valid('json')
|
||||
const changes = diff(cause, input)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(rootCauses).set(input).where(eq(rootCauses.id, cause.id))
|
||||
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.update', changes)
|
||||
}
|
||||
return c.json({ incident: await loadDetail(db, me, number) })
|
||||
})
|
||||
|
||||
.delete('/:id', requireRole('supervisor'), validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||
await db.delete(rootCauses).where(eq(rootCauses.id, cause.id))
|
||||
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.remove', { category: cause.category, description: cause.description })
|
||||
return c.json({ incident: await loadDetail(db, me, number) })
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('stats', () => {
|
||||
it('summarises the dashboard from real data', async () => {
|
||||
const north = await ctx.createSite('North')
|
||||
await ctx.createSite('South')
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
const report = (title: string, severity = 'medium', type = 'hazard') =>
|
||||
supervisor.post('/api/incidents', { title, type, severity, occurredAt: hoursAgo(3), siteId: north.id }).then((r) => r.body.incident.number as number)
|
||||
|
||||
const a = await report('A', 'low')
|
||||
await report('B', 'high', 'injury')
|
||||
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'triage' })
|
||||
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'closed' })
|
||||
const c = await report('C')
|
||||
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'Overdue', assigneeId: supervisor.user.id, dueDate: daysFromToday(-1) })
|
||||
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'This week', assigneeId: supervisor.user.id, dueDate: daysFromToday(3) })
|
||||
await supervisor.post(`/api/incidents/${c}/root-causes`, { category: 'equipment', description: 'Worn' })
|
||||
|
||||
const overview = (await supervisor.get('/api/stats/overview')).body
|
||||
expect(overview).toMatchObject({ openIncidents: 2, reportedLast7Days: 3, actionsDueThisWeek: 1, overdueActions: 1, actionProgress: { total: 2, completed: 0 } })
|
||||
expect(overview.byStatus).toMatchObject({ reported: 2, closed: 1 })
|
||||
expect(overview.averageCloseDays).toBeGreaterThanOrEqual(0)
|
||||
expect(overview.weeklyTrend).toHaveLength(12)
|
||||
expect(overview.weeklyTrend.at(-1).count).toBeGreaterThanOrEqual(1)
|
||||
expect(overview.bySite).toEqual([
|
||||
{ siteId: north.id, name: 'North', count: 3 },
|
||||
{ siteId: expect.any(String), name: 'South', count: 0 },
|
||||
])
|
||||
|
||||
const analytics = (await supervisor.get('/api/stats/analytics?days=30')).body
|
||||
expect(analytics).toMatchObject({ periodDays: 30, total: 3, topRootCause: { category: 'equipment', count: 1, share: 1 } })
|
||||
expect(analytics.byType).toEqual(expect.arrayContaining([{ type: 'hazard', count: 2 }, { type: 'injury', count: 1 }]))
|
||||
})
|
||||
|
||||
it("scopes a reporter's numbers to what they can see", async () => {
|
||||
const reporter = await ctx.createUser('reporter')
|
||||
expect((await reporter.get('/api/stats/overview')).body.openIncidents).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { AnalyticsStats, OverviewStats } from '../../shared/api-types.ts'
|
||||
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import { correctiveActions, incidents, investigations, rootCauses, sites } from '../db/schema.ts'
|
||||
import { addDays, localDate } from '../lib/dates.ts'
|
||||
import { currentUser, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { visibleTo } from '../lib/incidents.ts'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const OPEN_ACTION = ['open', 'in_progress'] as const
|
||||
|
||||
/** Monday 00:00 UTC of the week containing `date` (matches Postgres `date_trunc('week', …)` in UTC). */
|
||||
function weekStart(date: Date): Date {
|
||||
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7))
|
||||
return start
|
||||
}
|
||||
|
||||
export const statsRoutes = new Hono<AppEnv>()
|
||||
.get('/overview', requireAuth, async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const visible = visibleTo(db, me)
|
||||
const now = new Date()
|
||||
const today = localDate(now)
|
||||
|
||||
const statusRows = await db.select({ status: incidents.status, n: count() }).from(incidents).where(visible).groupBy(incidents.status)
|
||||
const byStatus = Object.fromEntries(incidentStatuses.map((status) => [status, 0])) as Record<IncidentStatus, number>
|
||||
for (const row of statusRows) byStatus[row.status] = row.n
|
||||
const openIncidents = Object.entries(byStatus).reduce((sum, [status, n]) => (status === 'closed' ? sum : sum + n), 0)
|
||||
|
||||
const [{ recent }] = await db
|
||||
.select({ recent: count() })
|
||||
.from(incidents)
|
||||
.where(and(visible, gte(incidents.createdAt, new Date(now.getTime() - 7 * DAY_MS))))
|
||||
|
||||
const actionsBase = db.select({ n: count() }).from(correctiveActions).innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
const [{ n: dueThisWeek }] = await actionsBase.where(
|
||||
and(visible, inArray(correctiveActions.status, OPEN_ACTION), gte(correctiveActions.dueDate, today), sql`${correctiveActions.dueDate} <= ${addDays(today, 7)}`),
|
||||
)
|
||||
const [{ n: overdue }] = await db
|
||||
.select({ n: count() })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(visible, inArray(correctiveActions.status, OPEN_ACTION), lt(correctiveActions.dueDate, today)))
|
||||
const progressRows = await db
|
||||
.select({ status: correctiveActions.status, n: count() })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(visible, ne(incidents.status, 'closed'), ne(correctiveActions.status, 'cancelled')))
|
||||
.groupBy(correctiveActions.status)
|
||||
const actionProgress = {
|
||||
total: progressRows.reduce((sum, row) => sum + row.n, 0),
|
||||
completed: progressRows.filter((row) => row.status === 'done' || row.status === 'verified').reduce((sum, row) => sum + row.n, 0),
|
||||
}
|
||||
|
||||
const [{ avgDays }] = await db
|
||||
.select({ avgDays: sql<string | null>`avg(extract(epoch from ${incidents.closedAt} - ${incidents.createdAt})) / 86400` })
|
||||
.from(incidents)
|
||||
.where(and(visible, isNotNull(incidents.closedAt), gte(incidents.closedAt, new Date(now.getTime() - 90 * DAY_MS))))
|
||||
|
||||
const firstWeek = weekStart(new Date(now.getTime() - 11 * 7 * DAY_MS))
|
||||
const weekExpr = sql<string>`to_char(date_trunc('week', ${incidents.occurredAt} at time zone 'UTC'), 'YYYY-MM-DD')`
|
||||
const weekRows = await db
|
||||
.select({ week: weekExpr, n: count() })
|
||||
.from(incidents)
|
||||
.where(and(visible, gte(incidents.occurredAt, firstWeek)))
|
||||
.groupBy(weekExpr)
|
||||
const weeklyTrend = Array.from({ length: 12 }, (_, i) => {
|
||||
const start = new Date(firstWeek.getTime() + i * 7 * DAY_MS).toISOString().slice(0, 10)
|
||||
return { weekStart: start, count: weekRows.find((row) => row.week === start)?.n ?? 0 }
|
||||
})
|
||||
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||
const siteRows = await db
|
||||
.select({ siteId: sites.id, name: sites.name, n: count(incidents.id) })
|
||||
.from(sites)
|
||||
.leftJoin(incidents, and(eq(incidents.siteId, sites.id), gte(incidents.occurredAt, yearStart), visible))
|
||||
.where(and(eq(sites.organizationId, me.organizationId), isNull(sites.archivedAt)))
|
||||
.groupBy(sites.id, sites.name)
|
||||
.orderBy(desc(count(incidents.id)), asc(sites.name))
|
||||
|
||||
const stats: OverviewStats = {
|
||||
openIncidents,
|
||||
reportedLast7Days: recent,
|
||||
actionsDueThisWeek: dueThisWeek,
|
||||
overdueActions: overdue,
|
||||
averageCloseDays: avgDays === null ? null : Math.round(Number(avgDays) * 10) / 10,
|
||||
byStatus,
|
||||
actionProgress,
|
||||
weeklyTrend,
|
||||
bySite: siteRows.map((row) => ({ siteId: row.siteId, name: row.name, count: row.n })),
|
||||
}
|
||||
return c.json(stats)
|
||||
})
|
||||
|
||||
.get('/analytics', requireAuth, validate('query', z.object({ days: z.coerce.number().int().min(7).max(730).default(90) })), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { days } = c.req.valid('query')
|
||||
const since = new Date(Date.now() - days * DAY_MS)
|
||||
const inPeriod = and(visibleTo(db, me), gte(incidents.occurredAt, since))
|
||||
|
||||
const byType = await db.select({ type: incidents.type, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.type).orderBy(desc(count()))
|
||||
const bySeverity = await db.select({ severity: incidents.severity, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.severity)
|
||||
const causes = await db
|
||||
.select({ category: rootCauses.category, count: count() })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
|
||||
.where(inPeriod)
|
||||
.groupBy(rootCauses.category)
|
||||
.orderBy(desc(count()))
|
||||
const totalCauses = causes.reduce((sum, row) => sum + row.count, 0)
|
||||
|
||||
const monthExpr = sql<string>`to_char(${correctiveActions.completedAt}, 'YYYY-MM')`
|
||||
const onTimeExpr = sql<boolean>`(${correctiveActions.completedAt})::date <= ${correctiveActions.dueDate}`
|
||||
const completed = await db
|
||||
.select({ month: monthExpr, onTime: onTimeExpr, count: count() })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(visibleTo(db, me), isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, since)))
|
||||
.groupBy(monthExpr, onTimeExpr)
|
||||
const onTime = completed.filter((row) => row.onTime).reduce((sum, row) => sum + row.count, 0)
|
||||
const allCompleted = completed.reduce((sum, row) => sum + row.count, 0)
|
||||
const months = [...new Set(completed.map((row) => row.month))].sort()
|
||||
|
||||
const stats: AnalyticsStats = {
|
||||
periodDays: days,
|
||||
total: byType.reduce((sum, row) => sum + row.count, 0),
|
||||
byType,
|
||||
bySeverity,
|
||||
topRootCause: causes[0] ? { category: causes[0].category, count: causes[0].count, share: causes[0].count / totalCauses } : null,
|
||||
onTimeActionRate: allCompleted ? onTime / allCompleted : null,
|
||||
monthlyCompletedActions: months.map((month) => ({
|
||||
month,
|
||||
onTime: completed.filter((row) => row.month === month && row.onTime).reduce((sum, row) => sum + row.count, 0),
|
||||
late: completed.filter((row) => row.month === month && !row.onTime).reduce((sum, row) => sum + row.count, 0),
|
||||
})),
|
||||
}
|
||||
return c.json(stats)
|
||||
})
|
||||
+32
-7
@@ -3,10 +3,11 @@ import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import { createAuthToken } from '../auth/tokens.ts'
|
||||
import { hashPassword } from '../auth/password.ts'
|
||||
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||
import { isUniqueViolation } from '../db/client.ts'
|
||||
import { users } from '../db/schema.ts'
|
||||
import { organizations, users } from '../db/schema.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
|
||||
@@ -22,6 +23,7 @@ export const publicUser = {
|
||||
}
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export const userRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireRole('safety_manager'), async (c) => {
|
||||
@@ -30,6 +32,18 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
return c.json({ users: rows })
|
||||
})
|
||||
|
||||
/** Active people who can lead investigations or be assigned actions. */
|
||||
.get('/assignable', requireRole('supervisor'), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const rows = await c
|
||||
.get('db')
|
||||
.select({ id: users.id, name: users.name, role: users.role })
|
||||
.from(users)
|
||||
.where(and(eq(users.organizationId, me.organizationId), eq(users.active, true)))
|
||||
.orderBy(asc(users.name))
|
||||
return c.json({ users: rows })
|
||||
})
|
||||
|
||||
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { password, ...input } = c.req.valid('json')
|
||||
@@ -37,10 +51,15 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
try {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
|
||||
.values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null })
|
||||
.returning(publicUser)
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } })
|
||||
return c.json({ user }, 201)
|
||||
if (!password) {
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
|
||||
const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS)
|
||||
c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name })
|
||||
}
|
||||
return c.json({ user, invited: !password }, 201)
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
|
||||
throw error
|
||||
@@ -66,14 +85,20 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
|
||||
}
|
||||
|
||||
const changes = diff(target, input)
|
||||
const { password, ...fields } = input
|
||||
const changes: Record<string, unknown> = diff(target, fields)
|
||||
if (password) changes.password = 'reset by admin'
|
||||
if (Object.keys(changes).length === 0) {
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
|
||||
return c.json({ user })
|
||||
}
|
||||
const [user] = await db.update(users).set(input).where(eq(users.id, id)).returning(publicUser)
|
||||
// Roles are re-read on every request, so only deactivation needs to end sessions.
|
||||
if (input.active === false) await deleteUserSessions(db, id)
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) })
|
||||
.where(eq(users.id, id))
|
||||
.returning(publicUser)
|
||||
// Roles are re-read on every request, so only deactivation and password resets need to end sessions.
|
||||
if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id)
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
|
||||
return c.json({ user })
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let siteId: string
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
|
||||
async function newIncident(severity = 'high') {
|
||||
const res = await reporter.post('/api/incidents', { title: 'Guard missing on press', type: 'hazard', severity, occurredAt: hoursAgo(1), siteId })
|
||||
return res.body.incident.number as number
|
||||
}
|
||||
const move = (client: typeof supervisor, number: number, to: string, note?: string) => client.post(`/api/incidents/${number}/transitions`, { to, note })
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
siteId = (await ctx.createSite()).id
|
||||
reporter = await ctx.createUser('reporter')
|
||||
supervisor = await ctx.createUser('supervisor')
|
||||
manager = await ctx.createUser('safety_manager')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('incident workflow', () => {
|
||||
it('runs an incident from report to close', async () => {
|
||||
const number = await newIncident()
|
||||
|
||||
expect((await move(reporter, number, 'triage')).status).toBe(403)
|
||||
let res = await move(supervisor, number, 'triage', 'Looking into it')
|
||||
expect(res.body.incident.status).toBe('triage')
|
||||
expect(res.body.incident.transitions.map((t: { to: string }) => t.to)).toEqual(['investigation', 'actions', 'closed'])
|
||||
|
||||
// High severity can't be closed straight from triage
|
||||
expect((await move(supervisor, number, 'closed')).body.error).toMatch(/must be investigated/)
|
||||
await move(supervisor, number, 'investigation')
|
||||
|
||||
// Need a root cause before moving on
|
||||
expect((await move(supervisor, number, 'actions')).status).toBe(409)
|
||||
res = await supervisor.put(`/api/incidents/${number}/investigation`, { method: 'five_whys', summary: 'Guard removed for maintenance and not refitted', leadId: supervisor.user.id })
|
||||
expect(res.body.incident.investigation).toMatchObject({ method: 'five_whys', lead: { id: supervisor.user.id } })
|
||||
res = await supervisor.post(`/api/incidents/${number}/root-causes`, {
|
||||
category: 'process',
|
||||
description: 'No lockout checklist for guard removal',
|
||||
whys: ['Guard was off', 'Maintenance removed it', 'No step to refit it'],
|
||||
})
|
||||
const rootCauseId = res.body.incident.rootCauses[0].id
|
||||
await move(supervisor, number, 'actions')
|
||||
|
||||
// Need finished actions before verification
|
||||
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/at least one corrective action/)
|
||||
res = await supervisor.post(`/api/incidents/${number}/actions`, {
|
||||
title: 'Add guard refit step to maintenance checklist',
|
||||
assigneeId: reporter.user.id,
|
||||
dueDate: daysFromToday(7),
|
||||
rootCauseId,
|
||||
})
|
||||
const action = res.body.incident.actions[0]
|
||||
expect(action).toMatchObject({ status: 'open', assignee: { id: reporter.user.id }, overdue: false })
|
||||
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/must be done/)
|
||||
|
||||
// The assignee (a reporter) can see the incident and report progress, but not edit the action
|
||||
expect((await reporter.get(`/api/incidents/${number}`)).status).toBe(200)
|
||||
expect((await reporter.patch(`/api/actions/${action.id}`, { title: 'Changed' })).status).toBe(403)
|
||||
expect((await reporter.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
|
||||
res = await reporter.patch(`/api/actions/${action.id}`, { status: 'done' })
|
||||
expect(res.body.action.completedAt).not.toBeNull()
|
||||
|
||||
await move(supervisor, number, 'verification')
|
||||
expect((await move(supervisor, number, 'closed')).status).toBe(403)
|
||||
expect((await move(manager, number, 'closed')).body.error).toMatch(/Verify every corrective action/)
|
||||
expect((await supervisor.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
|
||||
res = await manager.patch(`/api/actions/${action.id}`, { status: 'verified' })
|
||||
expect(res.body.action.verifiedBy).toMatchObject({ id: manager.user.id })
|
||||
|
||||
res = await move(manager, number, 'closed')
|
||||
expect(res.body.incident).toMatchObject({ status: 'closed' })
|
||||
expect(res.body.incident.closedAt).not.toBeNull()
|
||||
|
||||
// The timeline tells the whole story
|
||||
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
|
||||
expect(timeline.map((entry: { action: string }) => entry.action)).toEqual([
|
||||
'create',
|
||||
'status',
|
||||
'status',
|
||||
'investigation.update',
|
||||
'root_cause.add',
|
||||
'status',
|
||||
'action.add',
|
||||
'action.update',
|
||||
'status',
|
||||
'action.update',
|
||||
'status',
|
||||
])
|
||||
// The note on the first transition became a comment
|
||||
const comments = (await supervisor.get(`/api/incidents/${number}/comments`)).body.comments
|
||||
expect(comments[0]).toMatchObject({ body: 'Looking into it', author: { id: supervisor.user.id } })
|
||||
|
||||
// Reopening is for safety managers
|
||||
expect((await move(supervisor, number, 'investigation')).status).toBe(403)
|
||||
expect((await move(manager, number, 'investigation')).body.incident.closedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('lets low-severity incidents close straight from triage', async () => {
|
||||
const number = await newIncident('low')
|
||||
await move(supervisor, number, 'triage')
|
||||
expect((await move(supervisor, number, 'closed')).body.incident.status).toBe('closed')
|
||||
})
|
||||
|
||||
it('rejects transitions that skip steps', async () => {
|
||||
const number = await newIncident()
|
||||
expect((await move(manager, number, 'closed')).status).toBe(409)
|
||||
})
|
||||
|
||||
it('edits and removes root causes', async () => {
|
||||
const number = await newIncident()
|
||||
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'equipment', description: 'Worn belt' })
|
||||
const id = res.body.incident.rootCauses[0].id
|
||||
res = await supervisor.patch(`/api/root-causes/${id}`, { whys: ['Belt slipped', 'Not inspected'] })
|
||||
expect(res.body.incident.rootCauses[0].whys).toEqual(['Belt slipped', 'Not inspected'])
|
||||
res = await supervisor.delete(`/api/root-causes/${id}`)
|
||||
expect(res.body.incident.rootCauses).toEqual([])
|
||||
expect((await reporter.delete(`/api/root-causes/${id}`)).status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comments', () => {
|
||||
it('lets anyone who can see an incident comment on it', async () => {
|
||||
const number = await newIncident()
|
||||
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: 'It happened again today' })).status).toBe(201)
|
||||
const other = await ctx.createUser('reporter', 'outsider@example.com')
|
||||
expect((await other.post(`/api/incidents/${number}/comments`, { body: 'Hi' })).status).toBe(404)
|
||||
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: ' ' })).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('corrective actions', () => {
|
||||
it('lists my open and overdue actions and all actions for supervisors', async () => {
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Late one', assigneeId: manager.user.id, dueDate: daysFromToday(-2) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'On time', assigneeId: manager.user.id, dueDate: daysFromToday(10) })
|
||||
|
||||
const mine = (await manager.get('/api/actions')).body.actions
|
||||
expect(mine.map((a: { title: string }) => a.title)).toEqual(['Late one', 'On time'])
|
||||
expect(mine[0].overdue).toBe(true)
|
||||
const overdue = (await manager.get('/api/actions?filter=overdue')).body.actions
|
||||
expect(overdue.map((a: { title: string }) => a.title)).toEqual(['Late one'])
|
||||
|
||||
expect((await reporter.get('/api/actions?scope=all')).status).toBe(403)
|
||||
expect((await supervisor.get('/api/actions?scope=all')).body.actions.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('emails the assignee', async () => {
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Replace mat', assigneeId: manager.user.id })
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.last(manager.user.email)?.subject).toBe('Action assigned: Replace mat')
|
||||
})
|
||||
|
||||
it('sends one daily reminder per assignee for due and overdue actions', async () => {
|
||||
const assignee = await ctx.createUser('reporter', 'reminders@example.com')
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Overdue fix', assigneeId: assignee.user.id, dueDate: daysFromToday(-1) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Due tomorrow', assigneeId: assignee.user.id, dueDate: daysFromToday(1) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Next month', assigneeId: assignee.user.id, dueDate: daysFromToday(30) })
|
||||
|
||||
await ctx.notifier.sendDueReminders()
|
||||
await ctx.notifier.flush()
|
||||
const reminders = ctx.mailer.sent.filter((mail) => mail.to === assignee.user.email && mail.subject !== 'Action assigned: Overdue fix')
|
||||
const digest = reminders.find((mail) => mail.subject === '1 overdue corrective action')
|
||||
expect(digest?.text).toContain('Overdue fix')
|
||||
expect(digest?.text).toContain('Due tomorrow')
|
||||
expect(digest?.text).not.toContain('Next month')
|
||||
|
||||
const before = ctx.mailer.sent.length
|
||||
await ctx.notifier.sendDueReminders()
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.sent.length).toBe(before)
|
||||
})
|
||||
|
||||
it("can't be added to a closed incident", async () => {
|
||||
const number = await newIncident('low')
|
||||
await move(supervisor, number, 'triage')
|
||||
await move(supervisor, number, 'closed')
|
||||
expect((await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Too late' })).status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partial updates', () => {
|
||||
it('leave omitted fields alone', async () => {
|
||||
const number = await newIncident()
|
||||
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'people', description: 'Rushed', whys: ['Late shipment'] })
|
||||
const id = res.body.incident.rootCauses[0].id
|
||||
res = await supervisor.patch(`/api/root-causes/${id}`, { description: 'Rushed to meet shipment' })
|
||||
expect(res.body.incident.rootCauses[0]).toMatchObject({ description: 'Rushed to meet shipment', whys: ['Late shipment'] })
|
||||
|
||||
res = await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Preventive check', kind: 'preventive' })
|
||||
const actionId = res.body.incident.actions[0].id
|
||||
const updated = await supervisor.patch(`/api/actions/${actionId}`, { status: 'in_progress' })
|
||||
expect(updated.body.action).toMatchObject({ kind: 'preventive', status: 'in_progress' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const KEY_PATTERN = /^[0-9a-f-]{36}\.[a-z]{3,4}$/
|
||||
|
||||
/** Stores uploaded files on local disk under random names. */
|
||||
export class FileStorage {
|
||||
readonly dir: string
|
||||
|
||||
constructor(dir: string) {
|
||||
this.dir = dir
|
||||
}
|
||||
|
||||
async save(data: Uint8Array, extension: string): Promise<string> {
|
||||
await mkdir(this.dir, { recursive: true })
|
||||
const key = `${randomUUID()}.${extension}`
|
||||
await writeFile(this.path(key), data, { flag: 'wx' })
|
||||
return key
|
||||
}
|
||||
|
||||
read(key: string): Promise<Buffer> {
|
||||
return readFile(this.path(key))
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await rm(this.path(key), { force: true })
|
||||
}
|
||||
|
||||
private path(key: string) {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`Invalid storage key: ${key}`)
|
||||
return join(this.dir, key)
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -1,6 +1,12 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createApp } from '../app.ts'
|
||||
import { loadConfig } from '../config.ts'
|
||||
import { openDatabase } from '../db/client.ts'
|
||||
import { MemoryMailer } from '../mail.ts'
|
||||
import { Notifier } from '../notifications.ts'
|
||||
import { FileStorage } from '../storage.ts'
|
||||
|
||||
type App = ReturnType<typeof createApp>
|
||||
|
||||
@@ -15,12 +21,16 @@ export class Client {
|
||||
|
||||
async request(method: string, path: string, body?: unknown) {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['content-type'] = 'application/json'
|
||||
const isForm = body instanceof FormData
|
||||
if (body !== undefined && !isForm) headers['content-type'] = 'application/json'
|
||||
if (method !== 'GET') headers.origin = 'http://safety.test' // browsers send Origin on every non-GET request
|
||||
if (this.cookie) headers.cookie = this.cookie
|
||||
const res = await this.app.request(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
const payload = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||
const res = await this.app.request(path, { method, headers, body: payload })
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (setCookie) this.cookie = /max-age=0|expires=thu, 01 jan 1970/i.test(setCookie) ? undefined : setCookie.split(';')[0]
|
||||
const text = await res.text()
|
||||
const text = res.headers.get('content-type')?.includes('json') || res.status >= 400 ? await res.text() : ''
|
||||
if (!text && res.ok && res.status !== 204) return { status: res.status, body: Buffer.from(await res.arrayBuffer()), headers: res.headers }
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes
|
||||
const json: any = text ? JSON.parse(text) : null
|
||||
return { status: res.status, body: json, headers: res.headers }
|
||||
@@ -28,7 +38,9 @@ export class Client {
|
||||
|
||||
get = (path: string) => this.request('GET', path)
|
||||
post = (path: string, body?: unknown) => this.request('POST', path, body ?? {})
|
||||
put = (path: string, body: unknown) => this.request('PUT', path, body)
|
||||
patch = (path: string, body: unknown) => this.request('PATCH', path, body)
|
||||
delete = (path: string) => this.request('DELETE', path)
|
||||
}
|
||||
|
||||
export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' }
|
||||
@@ -36,7 +48,11 @@ export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', e
|
||||
/** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */
|
||||
export async function createTestContext() {
|
||||
const database = await openDatabase('memory://')
|
||||
const app = createApp({ db: database.db, config: { ...loadConfig({}), databaseUrl: 'memory://' } })
|
||||
const uploadDir = await mkdtemp(join(tmpdir(), 'safetylog-test-'))
|
||||
const config = { ...loadConfig({ PUBLIC_URL: 'http://safety.test' }), databaseUrl: 'memory://', uploadDir, maxUploadBytes: 1024 * 1024 }
|
||||
const mailer = new MemoryMailer()
|
||||
const notifier = new Notifier(database.db, mailer, config)
|
||||
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(uploadDir) })
|
||||
const admin = new Client(app)
|
||||
const setup = await admin.post('/api/setup', ADMIN)
|
||||
if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`)
|
||||
@@ -55,7 +71,27 @@ export async function createTestContext() {
|
||||
return res.body.site as { id: string; name: string }
|
||||
}
|
||||
|
||||
return { app, db: database.db, admin, anonymous: () => new Client(app), createUser, createSite, close: database.close }
|
||||
async function close() {
|
||||
await notifier.flush()
|
||||
await database.close()
|
||||
await rm(uploadDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return { app, db: database.db, admin, mailer, notifier, config, anonymous: () => new Client(app), createUser, createSite, close }
|
||||
}
|
||||
|
||||
export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString()
|
||||
|
||||
/** Pulls the token out of the link in an email body. */
|
||||
export function tokenFrom(text: string | undefined): string {
|
||||
const match = /token=([\w-]+)/.exec(text ?? '')
|
||||
if (!match) throw new Error(`No token in: ${text}`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export const daysFromToday = (days: number) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
Reference in new issue
Block a user