feat: implement incident reporting and management system
- 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:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user