feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions. - Create tests for permissions functionality. - Implement queries for user authentication and actions management. - Develop Actions page to display and manage corrective actions. - Create authentication pages for login, setup, and password reset. - Add IncidentDetail page to view incident details and associated actions. - Implement Settings page for user management and password changes.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7213
-643
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toCsv } from './csv.ts'
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('quotes commas, quotes and newlines', () => {
|
||||
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('a,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||
})
|
||||
|
||||
it('neutralises formula prefixes and blanks nulls', () => {
|
||||
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
type Cell = string | number | null | undefined
|
||||
|
||||
/**
|
||||
* RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a
|
||||
* formula are prefixed with an apostrophe (CSV injection).
|
||||
*/
|
||||
export function toCsv(header: string[], rows: Cell[][]): string {
|
||||
const cell = (value: Cell) => {
|
||||
let text = value === null || value === undefined ? '' : String(value)
|
||||
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||
}
|
||||
return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** ISO string for a Date, keeping nulls. */
|
||||
export const iso = <T extends Date | null>(date: T): T extends Date ? string : string | null => (date ? date.toISOString() : null) as never
|
||||
|
||||
/** Calendar date in the server's time zone (set TZ to the plant's zone), e.g. `2026-10-06`. */
|
||||
export function localDate(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function addDays(isoDate: string, days: number): string {
|
||||
const date = new Date(`${isoDate}T12:00:00`)
|
||||
date.setDate(date.getDate() + days)
|
||||
return localDate(date)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
|
||||
const bytes = (...values: (number | string)[]) => new Uint8Array(values.flatMap((v) => (typeof v === 'string' ? [...v].map((ch) => ch.charCodeAt(0)) : [v])))
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('recognises supported formats', () => {
|
||||
expect(detectFileType(bytes(0xff, 0xd8, 0xff, 0xe0))?.contentType).toBe('image/jpeg')
|
||||
expect(detectFileType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))?.contentType).toBe('image/webp')
|
||||
expect(detectFileType(bytes(0, 0, 0, 24, 'ftypheic'))?.contentType).toBe('image/heic')
|
||||
expect(detectFileType(bytes('%PDF-1.7'))?.contentType).toBe('application/pdf')
|
||||
})
|
||||
|
||||
it('rejects everything else', () => {
|
||||
expect(detectFileType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBeNull()
|
||||
expect(detectFileType(bytes('PK', 3, 4))).toBeNull()
|
||||
expect(detectFileType(new Uint8Array())).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface DetectedType {
|
||||
contentType: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
const startsWith = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((byte, i) => bytes[offset + i] === byte)
|
||||
const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to))
|
||||
|
||||
/**
|
||||
* Identifies photos and PDFs from their first bytes, ignoring the client's claimed type and file name.
|
||||
* Returns null for anything else, so only these formats are ever stored and served.
|
||||
*/
|
||||
export function detectFileType(bytes: Uint8Array): DetectedType | null {
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { contentType: 'image/jpeg', extension: 'jpg' }
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { contentType: 'image/png', extension: 'png' }
|
||||
if (ascii(bytes, 0, 6) === 'GIF87a' || ascii(bytes, 0, 6) === 'GIF89a') return { contentType: 'image/gif', extension: 'gif' }
|
||||
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return { contentType: 'image/webp', extension: 'webp' }
|
||||
if (ascii(bytes, 4, 8) === 'ftyp' && ['heic', 'heix', 'mif1', 'msf1'].includes(ascii(bytes, 8, 12))) return { contentType: 'image/heic', extension: 'heic' }
|
||||
if (ascii(bytes, 0, 5) === '%PDF-') return { contentType: 'application/pdf', extension: 'pdf' }
|
||||
return null
|
||||
}
|
||||
+5
-1
@@ -5,12 +5,16 @@ import type { ZodType } from 'zod'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import type { Notifier } from '../notifications.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
db: Db
|
||||
config: Config
|
||||
user: SessionUser | null
|
||||
notifier: Notifier
|
||||
storage: FileStorage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ export function validate<Target extends keyof ValidationTargets, Schema extends
|
||||
})
|
||||
}
|
||||
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 429, message: string) => new HTTPException(status, { message })
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message })
|
||||
|
||||
/** The signed-in user; use after `requireAuth`. */
|
||||
export function currentUser(c: Context<AppEnv>): SessionUser {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||
import { alias } from 'drizzle-orm/pg-core'
|
||||
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { availableTransitions } from '../../shared/workflow.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
const reporters = alias(users, 'reporter')
|
||||
|
||||
export function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||
}
|
||||
|
||||
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||
|
||||
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
reference: incidentReference(incident.number),
|
||||
title: incident.title,
|
||||
type: incident.type,
|
||||
severity: incident.severity,
|
||||
status: incident.status,
|
||||
occurredAt: iso(incident.occurredAt),
|
||||
reportedAt: iso(incident.createdAt),
|
||||
updatedAt: iso(incident.updatedAt),
|
||||
closedAt: iso(incident.closedAt),
|
||||
site: { id: incident.siteId, name: siteName },
|
||||
area: incident.areaId ? { id: incident.areaId, name: areaName ?? '' } : null,
|
||||
locationDetail: incident.locationDetail,
|
||||
description: incident.description,
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisors and above see every incident in their organization. Reporters see incidents they
|
||||
* reported, plus any where they are assigned a corrective action.
|
||||
*/
|
||||
export function visibleTo(db: Db, user: SessionUser): SQL | undefined {
|
||||
const inOrg = eq(incidents.organizationId, user.organizationId)
|
||||
if (hasRole(user.role, 'supervisor')) return inOrg
|
||||
const assigned = db
|
||||
.select({ id: correctiveActions.id })
|
||||
.from(correctiveActions)
|
||||
.where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id)))
|
||||
return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned)))
|
||||
}
|
||||
|
||||
export async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||
const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number)))
|
||||
if (!row) throw fail(404, 'Incident not found')
|
||||
return row
|
||||
}
|
||||
|
||||
const assignees = alias(users, 'assignee')
|
||||
const verifiers = alias(users, 'verifier')
|
||||
|
||||
export function selectActions(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
action: correctiveActions,
|
||||
incidentNumber: incidents.number,
|
||||
incidentTitle: incidents.title,
|
||||
assigneeName: assignees.name,
|
||||
verifierName: verifiers.name,
|
||||
})
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId))
|
||||
.leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById))
|
||||
}
|
||||
|
||||
type ActionRow = Awaited<ReturnType<ReturnType<typeof selectActions>['execute']>>[number]
|
||||
|
||||
export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress'
|
||||
|
||||
export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction {
|
||||
return {
|
||||
id: action.id,
|
||||
incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle },
|
||||
rootCauseId: action.rootCauseId,
|
||||
kind: action.kind,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null,
|
||||
dueDate: action.dueDate,
|
||||
status: action.status,
|
||||
overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today,
|
||||
completedAt: iso(action.completedAt),
|
||||
verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null,
|
||||
verifiedAt: iso(action.verifiedAt),
|
||||
createdAt: iso(action.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment {
|
||||
return {
|
||||
id: row.id,
|
||||
fileName: row.fileName,
|
||||
contentType: row.contentType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null,
|
||||
createdAt: iso(row.createdAt),
|
||||
url: `/api/attachments/${row.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDetail(db: Db, user: SessionUser, number: number): Promise<IncidentDetail> {
|
||||
const row = await findIncident(db, user, number)
|
||||
const incidentId = row.incident.id
|
||||
const leads = alias(users, 'lead')
|
||||
|
||||
const [investigationRow] = await db
|
||||
.select({ investigation: investigations, leadName: leads.name })
|
||||
.from(investigations)
|
||||
.leftJoin(leads, eq(leads.id, investigations.leadId))
|
||||
.where(eq(investigations.incidentId, incidentId))
|
||||
|
||||
const causes = investigationRow
|
||||
? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt))
|
||||
: []
|
||||
const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt))
|
||||
const uploaders = alias(users, 'uploader')
|
||||
const files = await db
|
||||
.select({ file: attachments, uploaderName: uploaders.name })
|
||||
.from(attachments)
|
||||
.leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById))
|
||||
.where(eq(attachments.incidentId, incidentId))
|
||||
.orderBy(asc(attachments.createdAt))
|
||||
|
||||
const investigation: Investigation | null = investigationRow
|
||||
? {
|
||||
id: investigationRow.investigation.id,
|
||||
lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null,
|
||||
method: investigationRow.investigation.method,
|
||||
summary: investigationRow.investigation.summary,
|
||||
startedAt: iso(investigationRow.investigation.startedAt),
|
||||
completedAt: iso(investigationRow.investigation.completedAt),
|
||||
}
|
||||
: null
|
||||
const actions = actionRows.map((action) => toAction(action))
|
||||
const manage = hasRole(user.role, 'supervisor')
|
||||
|
||||
return {
|
||||
...toIncident(row),
|
||||
investigation,
|
||||
rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })),
|
||||
actions,
|
||||
attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)),
|
||||
transitions: availableTransitions(row.incident.status, user.role, {
|
||||
severity: row.incident.severity,
|
||||
rootCauseCount: causes.length,
|
||||
actionStatuses: actions.map((action) => action.status),
|
||||
}),
|
||||
can: { edit: manage, manageInvestigation: manage, manageActions: manage },
|
||||
}
|
||||
}
|
||||
|
||||
/** Records an event on an incident's timeline. */
|
||||
export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record<string, unknown>) {
|
||||
return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes })
|
||||
}
|
||||
|
||||
/** Throws 400 unless the user is active in the organization. */
|
||||
export async function assertActiveUser(db: Db, organizationId: string, userId: string) {
|
||||
const [user] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true)))
|
||||
if (!user) throw fail(400, 'Unknown or inactive user')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user