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

- 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:
jayjennings committed 2026-10-06 11:20:44 -07:00
1 parent 708da6b9f7
commit 3f6dff5f24
94 files changed
+7213 -643

No files matched your search

+12
View File
@@ -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")
})
})
+14
View File
@@ -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'
}
+14
View File
@@ -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)
}
+19
View File
@@ -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()
})
})
+21
View File
@@ -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
View File
@@ -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 {
+187
View File
@@ -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')
}