feat: implement incident reporting and management system
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents.
- Implement visibility rules for users based on roles (reporters, supervisors).
- Create tests for incident reporting functionality to ensure correctness and reliability.
- Introduce site and area management routes, including validation for area hierarchy.
- Establish user management routes with role-based access control for creating and updating users.
- Add setup routes for initializing the application with an organization and admin user.
- Implement shared enums and schemas for consistent validation and data handling across the application.
- Create a test helper for setting up an in-memory database and API client for testing.
- Add TypeScript configuration for server-side code with strict type checking and module resolution.
This commit is contained in:
jayjennings committed 2026-10-06 10:51:18 -07:00
1 parent a382fa904a
commit c69eccb5c1
47 files changed
+6297 -45

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ADMIN, createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('first-run setup', () => {
it('reports that setup is done and refuses to run again', async () => {
const client = ctx.anonymous()
expect((await client.get('/api/setup')).body).toEqual({ needsSetup: false })
const again = await client.post('/api/setup', { ...ADMIN, email: 'intruder@example.com' })
expect(again.status).toBe(409)
})
it('signed the new admin in', async () => {
const me = await ctx.admin.get('/api/auth/me')
expect(me.status).toBe(200)
expect(me.body.user).toMatchObject({ email: ADMIN.email, role: 'admin' })
expect(me.body.organization.name).toBe(ADMIN.organizationName)
expect(me.body.user.passwordHash).toBeUndefined()
})
})
describe('sign in and out', () => {
it('rejects a wrong password with a generic message', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: ADMIN.email, password: 'nope' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('treats unknown emails the same as wrong passwords', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: 'nobody@example.com', password: 'whatever' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('accepts email in any case and sets an http-only cookie', async () => {
const client = ctx.anonymous()
const res = await client.post('/api/auth/login', { email: ADMIN.email.toUpperCase(), password: ADMIN.password })
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toMatch(/HttpOnly/)
expect(res.headers.get('set-cookie')).toMatch(/SameSite=Lax/)
expect((await client.get('/api/auth/me')).status).toBe(200)
expect((await client.post('/api/auth/logout')).status).toBe(204)
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('invalidates the session server-side on logout, even if the cookie is replayed', async () => {
const client = ctx.anonymous()
await client.post('/api/auth/login', { email: ADMIN.email, password: ADMIN.password })
const stolen = client.cookie
await client.post('/api/auth/logout')
client.cookie = stolen
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('rate-limits repeated attempts for one email', async () => {
const client = ctx.anonymous()
const email = 'ratelimit@example.com'
for (let i = 0; i < 10; i++) expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(401)
expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(429)
})
it('blocks deactivated users and ends their sessions', async () => {
const worker = await ctx.createUser('reporter', 'leaver@example.com')
expect((await worker.get('/api/auth/me')).status).toBe(200)
await ctx.admin.patch(`/api/users/${worker.user.id}`, { active: false })
expect((await worker.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'leaver@example.com', password: 'a sufficiently long password' })).status).toBe(401)
})
})
describe('change password', () => {
it('requires the current password and signs out other sessions', async () => {
const user = await ctx.createUser('supervisor', 'changer@example.com')
const otherDevice = ctx.anonymous()
await otherDevice.post('/api/auth/login', { email: 'changer@example.com', password: 'a sufficiently long password' })
expect((await user.post('/api/auth/password', { currentPassword: 'wrong', newPassword: 'a brand new password' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'short' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'a brand new password' })).status).toBe(204)
expect((await user.get('/api/auth/me')).status).toBe(200)
expect((await otherDevice.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'changer@example.com', password: 'a brand new password' })).status).toBe(200)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { changePasswordSchema, loginSchema } 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 { organizations, users } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
export function authRoutes() {
// 10 failed-or-successful attempts per email per 15 minutes
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
return new Hono<AppEnv>()
.post('/login', validate('json', loginSchema), async (c) => {
const { email, password } = c.req.valid('json')
if (!loginLimiter.attempt(email)) throw fail(429, 'Too many sign-in attempts. Try again in 15 minutes.')
const db = c.get('db')
const [user] = await db.select().from(users).where(eq(users.email, email))
const valid = user?.active && user.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyAgainstDummy(password)
if (!user || !valid) throw fail(401, 'Incorrect email or password')
loginLimiter.reset(email)
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id))
await startSession(c, user.id)
const [result] = await db.select(publicUser).from(users).where(eq(users.id, user.id))
return c.json({ user: result })
})
.post('/logout', async (c) => {
await endSession(c)
return c.body(null, 204)
})
.get('/me', requireAuth, async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [user] = await db.select(publicUser).from(users).where(eq(users.id, me.id))
const [organization] = await db.select({ id: organizations.id, name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
return c.json({ user, organization })
})
.post('/password', requireAuth, validate('json', changePasswordSchema), async (c) => {
const me = currentUser(c)
const { currentPassword, newPassword } = c.req.valid('json')
const db = c.get('db')
const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, me.id))
if (!user.passwordHash || !(await verifyPassword(currentPassword, user.passwordHash))) throw fail(400, 'Current password is incorrect')
await db.update(users).set({ passwordHash: await hashPassword(newPassword) }).where(eq(users.id, me.id))
// Sign out everywhere else
await deleteUserSessions(db, me.id)
await startSession(c, me.id)
return c.body(null, 204)
})
}
+134
View File
@@ -0,0 +1,134 @@
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { auditLog } from '../db/schema.ts'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let site: { id: string }
let area: { id: string }
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
const report = (overrides: Record<string, unknown> = {}) => ({
title: 'Forklift reversed into pallet stack',
type: 'near_miss',
severity: 'high',
occurredAt: hoursAgo(2),
siteId: site.id,
...overrides,
})
beforeAll(async () => {
ctx = await createTestContext()
site = await ctx.createSite('North Campus')
area = (await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C' })).body.area
reporter = await ctx.createUser('reporter')
supervisor = await ctx.createUser('supervisor')
})
afterAll(() => ctx.close())
describe('reporting', () => {
it('creates an incident with a sequential reference and the reporter recorded', async () => {
const res = await reporter.post('/api/incidents', report({ areaId: area.id, locationDetail: 'Dock door 3' }))
expect(res.status).toBe(201)
expect(res.body.incident).toMatchObject({
number: 1,
reference: 'INC-0001',
status: 'reported',
site: { id: site.id, name: 'North Campus' },
area: { id: area.id, name: 'Loading bay C' },
reporter: { id: reporter.user.id, name: 'reporter user' },
})
})
it('assigns distinct numbers to simultaneous reports', async () => {
const results = await Promise.all(Array.from({ length: 8 }, (_, i) => reporter.post('/api/incidents', report({ title: `Concurrent ${i}` }))))
const numbers = results.map((r) => r.body.incident.number)
expect(new Set(numbers).size).toBe(8)
})
it('keeps anonymous reports anonymous', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Blocked fire exit', anonymous: true }))
expect(res.body.incident).toMatchObject({ anonymous: true, reporter: null })
})
it('validates location and time', async () => {
const otherSite = await ctx.createSite('South Campus')
expect((await reporter.post('/api/incidents', report({ siteId: otherSite.id, areaId: area.id }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ occurredAt: new Date(Date.now() + 86_400_000).toISOString() }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ type: 'explosion' }))).status).toBe(400)
})
it('writes an audit entry', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Audited' }))
const entries = await ctx.db.select().from(auditLog).where(eq(auditLog.entityId, res.body.incident.id))
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ action: 'create', actorId: reporter.user.id })
})
})
describe('visibility', () => {
it('shows reporters only their own incidents', async () => {
const other = await ctx.createUser('reporter', 'other-reporter@example.com')
const mine = await other.post('/api/incidents', report({ title: 'Only mine' }))
const list = await other.get('/api/incidents')
expect(list.body.items.map((i: { title: string }) => i.title)).toEqual(['Only mine'])
expect((await reporter.get(`/api/incidents/${mine.body.incident.number}`)).status).toBe(404)
})
it('shows supervisors every incident', async () => {
const list = await supervisor.get('/api/incidents?pageSize=100')
expect(list.body.total).toBeGreaterThan(10)
})
})
describe('listing', () => {
it('paginates newest first', async () => {
const page1 = await supervisor.get('/api/incidents?pageSize=3&page=1')
const page2 = await supervisor.get('/api/incidents?pageSize=3&page=2')
expect(page1.body.items).toHaveLength(3)
expect(page1.body.items[0].id).not.toBe(page2.body.items[0].id)
const times = page1.body.items.map((i: { occurredAt: string }) => i.occurredAt)
expect([...times].sort().reverse()).toEqual(times)
})
it('searches title and reference, treating % and _ literally', async () => {
await reporter.post('/api/incidents', report({ title: 'Oil leak under press 4', type: 'hazard', severity: 'medium' }))
expect((await supervisor.get('/api/incidents?q=press')).body.items[0].title).toBe('Oil leak under press 4')
expect((await supervisor.get('/api/incidents?q=INC-0001')).body.items[0].number).toBe(1)
expect((await supervisor.get('/api/incidents?q=%25')).body.total).toBe(0)
})
it('filters by type, severity and status', async () => {
const hazards = await supervisor.get('/api/incidents?type=hazard&severity=medium')
expect(hazards.body.items.every((i: { type: string; severity: string }) => i.type === 'hazard' && i.severity === 'medium')).toBe(true)
expect((await supervisor.get('/api/incidents?status=closed')).body.total).toBe(0)
expect((await supervisor.get('/api/incidents?status=bogus')).status).toBe(400)
})
})
describe('updating', () => {
it('lets supervisors update and close incidents, 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)
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 [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
expect(reopened.body.incident.closedAt).toBeNull()
})
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 })
})
})
+191
View File
@@ -0,0 +1,191 @@
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
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 { requireAuth, requireRole } from '../auth/middleware.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 { currentUser, fail, validate, type AppEnv } from '../lib/http.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
}
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
.select({ id: sites.id })
.from(sites)
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
if (!site) throw fail(400, 'Unknown site')
if (areaId) {
const [area] = await db
.select({ id: areas.id })
.from(areas)
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
if (!area) throw fail(400, 'Area must belong to the selected site')
}
}
function parseOccurredAt(value: string): Date {
const date = new Date(value)
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
return date
}
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
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 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 [{ 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 })
})
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
const me = currentUser(c)
const { anonymous, occurredAt, ...input } = c.req.valid('json')
const db = c.get('db')
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
const occurred = parseOccurredAt(occurredAt)
const number = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
.values({ organizationId: me.organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
.returning({ id: incidents.id })
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 },
})
return lastNumber
})
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
return c.json({ incident: toDto(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) })
})
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const { occurredAt, ...input } = c.req.valid('json')
const siteId = input.siteId ?? incident.siteId
let areaId = input.areaId !== undefined ? input.areaId : incident.areaId
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 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 })
}
const row = await findIncident(db, me, incident.number)
return c.json({ incident: toDto(row) })
})
+42
View File
@@ -0,0 +1,42 @@
import { sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { setupSchema } from '../../shared/schemas.ts'
import { startSession } from '../auth/middleware.ts'
import { hashPassword } from '../auth/password.ts'
import type { Db } from '../db/client.ts'
import { organizations, users } from '../db/schema.ts'
import { audit } from '../lib/audit.ts'
import { validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
const SETUP_LOCK = 717_001
async function hasUsers(db: Db) {
const rows = await db.select({ id: users.id }).from(users).limit(1)
return rows.length > 0
}
/** First-run setup: creates the organization and its first admin. Only works while there are no users. */
export const setupRoutes = new Hono<AppEnv>()
.get('/', async (c) => c.json({ needsSetup: !(await hasUsers(c.get('db'))) }))
.post('/', validate('json', setupSchema), async (c) => {
const input = c.req.valid('json')
const passwordHash = await hashPassword(input.password)
const user = await c.get('db').transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK})`)
if (await hasUsers(tx)) return null
const [organization] = await tx.insert(organizations).values({ name: input.organizationName }).returning()
const [admin] = await tx
.insert(users)
.values({ organizationId: organization.id, email: input.email, name: input.name, role: 'admin', passwordHash })
.returning(publicUser)
await audit(tx, { organizationId: organization.id, actorId: admin.id, entityType: 'organization', entityId: organization.id, action: 'setup' })
return admin
})
if (!user) return c.json({ error: 'Setup has already been completed' }, 409)
await startSession(c, user.id)
return c.json({ user }, 201)
})
+51
View File
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('sites and areas', () => {
it('builds a location tree', async () => {
const site = await ctx.createSite('North Campus')
const building = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Warehouse 04', kind: 'building' })
expect(building.status).toBe(201)
const bay = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C', parentId: building.body.area.id })
expect(bay.body.area).toMatchObject({ kind: 'area', parentId: building.body.area.id })
const list = await ctx.admin.get('/api/sites')
const north = list.body.sites.find((s: { name: string }) => s.name === 'North Campus')
expect(north.areas.map((a: { name: string }) => a.name)).toEqual(['Loading bay C', 'Warehouse 04'])
})
it('rejects a parent area from another site', async () => {
const a = await ctx.createSite('Site A')
const b = await ctx.createSite('Site B')
const areaInA = await ctx.admin.post(`/api/sites/${a.id}/areas`, { name: 'Line 1' })
const res = await ctx.admin.post(`/api/sites/${b.id}/areas`, { name: 'Line 2', parentId: areaInA.body.area.id })
expect(res.status).toBe(400)
})
it('rejects duplicate site names', async () => {
await ctx.createSite('Duplicate')
expect((await ctx.admin.post('/api/sites', { name: 'Duplicate' })).status).toBe(409)
})
it('hides archived sites unless asked', async () => {
const site = await ctx.createSite('Old Plant')
await ctx.admin.patch(`/api/sites/${site.id}`, { archived: true })
const names = (await ctx.admin.get('/api/sites')).body.sites.map((s: { name: string }) => s.name)
expect(names).not.toContain('Old Plant')
const all = (await ctx.admin.get('/api/sites?includeArchived=true')).body.sites.map((s: { name: string }) => s.name)
expect(all).toContain('Old Plant')
})
it('lets everyone read but only safety managers change locations', async () => {
const reporter = await ctx.createUser('reporter')
const supervisor = await ctx.createUser('supervisor')
expect((await reporter.get('/api/sites')).status).toBe(200)
expect((await supervisor.post('/api/sites', { name: 'Nope' })).status).toBe(403)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { createAreaSchema, createSiteSchema, updateAreaSchema, updateSiteSchema } from '../../shared/schemas.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts'
import { isUniqueViolation, type Db } from '../db/client.ts'
import { areas, sites } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const idParam = z.object({ id: z.uuid() })
const listQuery = z.object({ includeArchived: z.enum(['true', 'false']).optional() })
async function findSite(db: Db, organizationId: string, id: string) {
const [site] = await db.select().from(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)))
if (!site) throw fail(404, 'Site not found')
return site
}
const archivedAt = (archived: boolean | undefined) => (archived === undefined ? undefined : archived ? new Date() : null)
export const siteRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', listQuery), async (c) => {
const me = currentUser(c)
const includeArchived = c.req.valid('query').includeArchived === 'true'
const db = c.get('db')
const siteRows = await db
.select()
.from(sites)
.where(and(eq(sites.organizationId, me.organizationId), includeArchived ? undefined : isNull(sites.archivedAt)))
.orderBy(asc(sites.name))
const areaRows = siteRows.length
? await db
.select()
.from(areas)
.where(and(inArray(areas.siteId, siteRows.map((site) => site.id)), includeArchived ? undefined : isNull(areas.archivedAt)))
.orderBy(asc(areas.name))
: []
return c.json({ sites: siteRows.map((site) => ({ ...site, areas: areaRows.filter((area) => area.siteId === site.id) })) })
})
.post('/', requireRole('safety_manager'), validate('json', createSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
try {
const [site] = await db
.insert(sites)
.values({ ...c.req.valid('json'), organizationId: me.organizationId })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'create', changes: { name: site.name } })
return c.json({ site: { ...site, areas: [] } }, 201)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(site, update)
if (Object.keys(changes).length === 0) return c.json({ site })
try {
const [updated] = await db.update(sites).set(update).where(eq(sites.id, site.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'update', changes })
return c.json({ site: updated })
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.post('/:id/areas', requireRole('safety_manager'), validate('param', idParam), validate('json', createAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const input = c.req.valid('json')
if (input.parentId) {
const [parent] = await db.select({ id: areas.id }).from(areas).where(and(eq(areas.id, input.parentId), eq(areas.siteId, site.id)))
if (!parent) throw fail(400, 'Parent area must belong to the same site')
}
const [area] = await db
.insert(areas)
.values({ ...input, siteId: site.id })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'create', changes: { name: area.name, siteId: site.id } })
return c.json({ area }, 201)
})
export const areaRoutes = new Hono<AppEnv>().patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await db
.select({ area: areas })
.from(areas)
.innerJoin(sites, eq(sites.id, areas.siteId))
.where(and(eq(areas.id, c.req.valid('param').id), eq(sites.organizationId, me.organizationId)))
if (!row) throw fail(404, 'Area not found')
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(row.area, update)
if (Object.keys(changes).length === 0) return c.json({ area: row.area })
const [area] = await db.update(areas).set(update).where(eq(areas.id, row.area.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'update', changes })
return c.json({ area })
})
+56
View File
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('users', () => {
it('lets admins create users and rejects duplicate emails', async () => {
const body = { name: 'Sam Rivera', email: 'Sam@Example.com', role: 'supervisor', password: 'a sufficiently long password' }
const created = await ctx.admin.post('/api/users', body)
expect(created.status).toBe(201)
expect(created.body.user).toMatchObject({ email: 'sam@example.com', role: 'supervisor', active: true })
expect(created.body.user.passwordHash).toBeUndefined()
expect((await ctx.admin.post('/api/users', body)).status).toBe(409)
})
it('validates input', async () => {
const res = await ctx.admin.post('/api/users', { name: '', email: 'not-an-email', role: 'boss', password: 'short' })
expect(res.status).toBe(400)
expect(res.body.issues.map((issue: { path: string }) => issue.path).sort()).toEqual(['email', 'name', 'password', 'role'])
})
it('restricts listing to safety managers and creating to admins', async () => {
const supervisor = await ctx.createUser('supervisor')
const manager = await ctx.createUser('safety_manager')
expect((await supervisor.get('/api/users')).status).toBe(403)
expect((await manager.get('/api/users')).status).toBe(200)
expect((await manager.post('/api/users', { name: 'X', email: 'x@example.com', role: 'admin', password: 'a sufficiently long password' })).status).toBe(403)
expect((await ctx.anonymous().get('/api/users')).status).toBe(401)
})
it('returns 404 for unknown or malformed ids', async () => {
expect((await ctx.admin.patch('/api/users/00000000-0000-4000-8000-000000000000', { name: 'Nobody' })).status).toBe(404)
expect((await ctx.admin.patch('/api/users/not-a-uuid', { name: 'Nobody' })).status).toBe(400)
})
it('keeps at least one active admin', async () => {
const me = await ctx.admin.get('/api/auth/me')
const adminId = me.body.user.id
expect((await ctx.admin.patch(`/api/users/${adminId}`, { role: 'reporter' })).status).toBe(409)
expect((await ctx.admin.patch(`/api/users/${adminId}`, { active: false })).status).toBe(400)
const second = await ctx.createUser('admin', 'second-admin@example.com')
const demoted = await second.patch(`/api/users/${adminId}`, { role: 'safety_manager' })
expect(demoted.status).toBe(200)
expect(demoted.body.user.role).toBe('safety_manager')
// The demoted user stays signed in but loses admin rights immediately
expect((await ctx.admin.get('/api/auth/me')).status).toBe(200)
expect((await ctx.admin.post('/api/users', { name: 'Y', email: 'y@example.com', role: 'reporter', password: 'a sufficiently long password' })).status).toBe(403)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { and, asc, eq, ne } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.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 { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
/** Columns safe to return to clients (never the password hash). */
export const publicUser = {
id: users.id,
email: users.email,
name: users.name,
role: users.role,
active: users.active,
lastLoginAt: users.lastLoginAt,
createdAt: users.createdAt,
}
const idParam = z.object({ id: z.uuid() })
export const userRoutes = new Hono<AppEnv>()
.get('/', requireRole('safety_manager'), async (c) => {
const me = currentUser(c)
const rows = await c.get('db').select(publicUser).from(users).where(eq(users.organizationId, me.organizationId)).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')
const db = c.get('db')
try {
const [user] = await db
.insert(users)
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
.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)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
throw error
}
})
.patch('/:id', requireRole('admin'), validate('param', idParam), validate('json', updateUserSchema), async (c) => {
const me = currentUser(c)
const { id } = c.req.valid('param')
const input = c.req.valid('json')
const db = c.get('db')
const [target] = await db.select().from(users).where(and(eq(users.id, id), eq(users.organizationId, me.organizationId)))
if (!target) throw fail(404, 'User not found')
if (target.id === me.id && input.active === false) throw fail(400, 'You cannot deactivate your own account')
const losesAdmin = target.role === 'admin' && target.active && ((input.role && input.role !== 'admin') || input.active === false)
if (losesAdmin) {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.organizationId, me.organizationId), eq(users.role, 'admin'), eq(users.active, true), ne(users.id, target.id)))
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
}
const changes = diff(target, input)
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)
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
return c.json({ user })
})