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,183 @@
|
||||
// Response shapes returned by the API. Dates are ISO 8601 strings.
|
||||
import type {
|
||||
actionKinds,
|
||||
actionStatuses,
|
||||
AreaKind,
|
||||
IncidentStatus,
|
||||
IncidentType,
|
||||
rcaMethods,
|
||||
Role,
|
||||
rootCauseCategories,
|
||||
Severity,
|
||||
} from './enums.ts'
|
||||
|
||||
export type ActionKind = (typeof actionKinds)[number]
|
||||
export type ActionStatus = (typeof actionStatuses)[number]
|
||||
export type RcaMethod = (typeof rcaMethods)[number]
|
||||
export type RootCauseCategory = (typeof rootCauseCategories)[number]
|
||||
|
||||
export interface UserRef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
active: boolean
|
||||
lastLoginAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string
|
||||
siteId: string
|
||||
parentId: string | null
|
||||
name: string
|
||||
kind: AreaKind
|
||||
archivedAt: string | null
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
address: string | null
|
||||
timezone: string
|
||||
archivedAt: string | null
|
||||
areas: Area[]
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string
|
||||
number: number
|
||||
reference: string
|
||||
title: string
|
||||
type: IncidentType
|
||||
severity: Severity
|
||||
status: IncidentStatus
|
||||
occurredAt: string
|
||||
reportedAt: string
|
||||
updatedAt: string
|
||||
closedAt: string | null
|
||||
site: { id: string; name: string }
|
||||
area: { id: string; name: string } | null
|
||||
locationDetail: string | null
|
||||
description: string | null
|
||||
immediateAction: string | null
|
||||
anonymous: boolean
|
||||
reporter: UserRef | null
|
||||
}
|
||||
|
||||
export interface Investigation {
|
||||
id: string
|
||||
lead: UserRef | null
|
||||
method: RcaMethod
|
||||
summary: string | null
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface RootCause {
|
||||
id: string
|
||||
category: RootCauseCategory
|
||||
description: string
|
||||
whys: string[]
|
||||
}
|
||||
|
||||
export interface CorrectiveAction {
|
||||
id: string
|
||||
incident: { number: number; reference: string; title: string }
|
||||
rootCauseId: string | null
|
||||
kind: ActionKind
|
||||
title: string
|
||||
description: string | null
|
||||
assignee: UserRef | null
|
||||
dueDate: string | null
|
||||
status: ActionStatus
|
||||
overdue: boolean
|
||||
completedAt: string | null
|
||||
verifiedBy: UserRef | null
|
||||
verifiedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
uploadedBy: UserRef | null
|
||||
createdAt: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface TransitionOption {
|
||||
to: IncidentStatus
|
||||
label: string
|
||||
/** Why the transition can't happen yet; absent when it is allowed. */
|
||||
blockedReason?: string
|
||||
}
|
||||
|
||||
export interface IncidentDetail extends Incident {
|
||||
investigation: Investigation | null
|
||||
rootCauses: RootCause[]
|
||||
actions: CorrectiveAction[]
|
||||
attachments: Attachment[]
|
||||
transitions: TransitionOption[]
|
||||
/** What the signed-in user may do on this incident. */
|
||||
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean }
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
author: UserRef | null
|
||||
body: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
id: number
|
||||
at: string
|
||||
actor: UserRef | null
|
||||
action: string
|
||||
changes: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface OverviewStats {
|
||||
openIncidents: number
|
||||
reportedLast7Days: number
|
||||
actionsDueThisWeek: number
|
||||
overdueActions: number
|
||||
/** Average days from report to close for incidents closed in the last 90 days. */
|
||||
averageCloseDays: number | null
|
||||
byStatus: Record<IncidentStatus, number>
|
||||
actionProgress: { completed: number; total: number }
|
||||
/** Incidents per week for the last 12 weeks, oldest first. */
|
||||
weeklyTrend: { weekStart: string; count: number }[]
|
||||
bySite: { siteId: string; name: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface AnalyticsStats {
|
||||
periodDays: number
|
||||
total: number
|
||||
byType: { type: IncidentType; count: number }[]
|
||||
bySeverity: { severity: Severity; count: number }[]
|
||||
topRootCause: { category: RootCauseCategory; count: number; share: number } | null
|
||||
/** Share of completed actions finished on or before their due date. */
|
||||
onTimeActionRate: number | null
|
||||
monthlyCompletedActions: { month: string; onTime: number; late: number }[]
|
||||
}
|
||||
+61
-7
@@ -1,6 +1,6 @@
|
||||
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
|
||||
import { z } from 'zod'
|
||||
import { areaKinds, incidentStatuses, incidentTypes, roles, severities } from './enums.ts'
|
||||
import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
|
||||
|
||||
const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
|
||||
const password = z.string().min(10, 'Use at least 10 characters').max(256)
|
||||
@@ -14,9 +14,11 @@ export const loginSchema = z.object({ email, password: z.string().min(1).max(256
|
||||
|
||||
export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password })
|
||||
|
||||
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password })
|
||||
/** Without a password, the user is emailed an invitation to set one. */
|
||||
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() })
|
||||
|
||||
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional() })
|
||||
/** `password` lets an admin set a new password when email isn't available. */
|
||||
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() })
|
||||
|
||||
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
|
||||
|
||||
@@ -39,10 +41,57 @@ export const createIncidentSchema = z.object({
|
||||
anonymous: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const updateIncidentSchema = createIncidentSchema
|
||||
.omit({ anonymous: true })
|
||||
.partial()
|
||||
.extend({ status: z.enum(incidentStatuses).optional() })
|
||||
/** Status changes go through transitions, not updates. */
|
||||
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial()
|
||||
|
||||
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
|
||||
|
||||
export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) })
|
||||
|
||||
export const investigationSchema = z.object({
|
||||
leadId: z.uuid().nullish(),
|
||||
method: z.enum(rcaMethods).optional(),
|
||||
summary: optionalText(20_000),
|
||||
completed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const rootCauseFields = {
|
||||
category: z.enum(rootCauseCategories),
|
||||
description: z.string().trim().min(1).max(2_000),
|
||||
whys: z.array(z.string().trim().min(1).max(1_000)).max(10),
|
||||
}
|
||||
|
||||
export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) })
|
||||
|
||||
// Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps
|
||||
// defaults, so a partial update would silently reset omitted fields.
|
||||
export const updateRootCauseSchema = z.object(rootCauseFields).partial()
|
||||
|
||||
const isoDate = z.iso.date()
|
||||
|
||||
const actionFields = {
|
||||
title: name,
|
||||
description: optionalText(5_000),
|
||||
kind: z.enum(actionKinds),
|
||||
assigneeId: z.uuid().nullish(),
|
||||
dueDate: isoDate.nullish(),
|
||||
rootCauseId: z.uuid().nullish(),
|
||||
}
|
||||
|
||||
export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') })
|
||||
|
||||
export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial()
|
||||
|
||||
export const actionListQuery = z.object({
|
||||
scope: z.enum(['mine', 'all']).default('mine'),
|
||||
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
|
||||
})
|
||||
|
||||
export const emailOnlySchema = z.object({ email })
|
||||
|
||||
export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
|
||||
|
||||
export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password })
|
||||
|
||||
export const incidentListQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
@@ -57,4 +106,9 @@ export const incidentListQuery = z.object({
|
||||
export type SetupInput = z.infer<typeof setupSchema>
|
||||
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
||||
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
|
||||
export type CreateActionInput = z.input<typeof createActionSchema>
|
||||
export type UpdateActionInput = z.input<typeof updateActionSchema>
|
||||
export type RootCauseInput = z.input<typeof rootCauseSchema>
|
||||
export type UpdateRootCauseInput = z.input<typeof updateRootCauseSchema>
|
||||
export type InvestigationInput = z.input<typeof investigationSchema>
|
||||
export type IncidentListQuery = z.infer<typeof incidentListQuery>
|
||||
@@ -0,0 +1,55 @@
|
||||
// Incident status workflow, shared so the web app can show the same options and reasons the API enforces.
|
||||
import type { ActionStatus } from './api-types.ts'
|
||||
import { hasRole, type IncidentStatus, type Role, type Severity } from './enums.ts'
|
||||
|
||||
export interface Transition {
|
||||
from: IncidentStatus
|
||||
to: IncidentStatus
|
||||
minRole: Role
|
||||
label: string
|
||||
}
|
||||
|
||||
export const transitions: Transition[] = [
|
||||
{ from: 'reported', to: 'triage', minRole: 'supervisor', label: 'Start triage' },
|
||||
{ from: 'triage', to: 'investigation', minRole: 'supervisor', label: 'Start investigation' },
|
||||
{ from: 'triage', to: 'actions', minRole: 'supervisor', label: 'Skip to corrective actions' },
|
||||
{ from: 'triage', to: 'closed', minRole: 'supervisor', label: 'Close: no further action' },
|
||||
{ from: 'investigation', to: 'actions', minRole: 'supervisor', label: 'Move to corrective actions' },
|
||||
{ from: 'actions', to: 'investigation', minRole: 'supervisor', label: 'Back to investigation' },
|
||||
{ from: 'actions', to: 'verification', minRole: 'supervisor', label: 'Send for verification' },
|
||||
{ from: 'verification', to: 'actions', minRole: 'safety_manager', label: 'Return to actions' },
|
||||
{ from: 'verification', to: 'closed', minRole: 'safety_manager', label: 'Verify and close' },
|
||||
{ from: 'closed', to: 'investigation', minRole: 'safety_manager', label: 'Reopen' },
|
||||
]
|
||||
|
||||
export interface WorkflowFacts {
|
||||
severity: Severity
|
||||
rootCauseCount: number
|
||||
actionStatuses: ActionStatus[]
|
||||
}
|
||||
|
||||
/** Why a transition isn't possible yet, or undefined if it is. */
|
||||
export function transitionBlocker(transition: Transition, facts: WorkflowFacts): string | undefined {
|
||||
const live = facts.actionStatuses.filter((status) => status !== 'cancelled')
|
||||
if (transition.from === 'triage' && transition.to === 'closed' && (facts.severity === 'high' || facts.severity === 'critical')) {
|
||||
return 'High and critical incidents must be investigated before closing'
|
||||
}
|
||||
if (transition.to === 'actions' && transition.from === 'investigation' && facts.rootCauseCount === 0) {
|
||||
return 'Record at least one root cause first'
|
||||
}
|
||||
if (transition.to === 'verification') {
|
||||
if (live.length === 0) return 'Add at least one corrective action first'
|
||||
if (live.some((status) => status === 'open' || status === 'in_progress')) return 'All corrective actions must be done first'
|
||||
}
|
||||
if (transition.from === 'verification' && transition.to === 'closed' && live.some((status) => status !== 'verified')) {
|
||||
return 'Verify every corrective action first'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Transitions from `status` that `role` may perform, with blockers explained. */
|
||||
export function availableTransitions(status: IncidentStatus, role: Role, facts: WorkflowFacts) {
|
||||
return transitions
|
||||
.filter((t) => t.from === status && hasRole(role, t.minRole))
|
||||
.map((t) => ({ to: t.to, label: t.label, blockedReason: transitionBlocker(t, facts) }))
|
||||
}
|
||||
Reference in new issue
Block a user