Incidents Domain
Technical lifecycle, ownership, and audit trail of operational incidents
Overview
The incidents domain tracks safety and operational issues end-to-end: filing, ownership, status workflow, escalation, commenting, and notification fan-out. Every mutation is captured in the same database transaction as the change, so the audit log never drifts from the data.
Database Schema
Incidents (incidents)
The primary record for an operational issue. Defined in packages/database/src/schema/reports.ts.
| Column | Type | Description |
|---|---|---|
id | text | Primary key (UUID) |
userId | text | The staff member who reported the incident (FK → users.clerkId) |
teamId | text | Configured logical tenant identifier; no persisted teams table |
locationId | text | The reporting location (FK → locations.id, non-null) |
type | text | Injury, Near Miss, Spill / Slip Hazard, Complaint, Maintenance, or Stockout (from INCIDENT_TYPES) |
severity | text | low, medium, high, or critical (from INCIDENT_SEVERITIES) |
priority | integer | 1 (critical) / 2 (high) / 3 (default) — auto-derived from severity, overridable from the UI |
isInventoryAlert | boolean | true for Stockout reports; bypasses the priority gate for Slack notification |
reopened | boolean | true once a resolved incident is moved back to investigating |
status | text | reported, investigating, deferred, or resolved (from INCIDENT_STATUSES) |
visibility | text | manager_only (default) or employee_visible (from INCIDENT_VISIBILITIES) |
ownerUserId | text | The staff member who owns resolution (nullable; admin/manager only) |
ownedAt | timestamp | When ownership was assigned |
ownedBy | text | The staff member who assigned the owner (audit trail) |
date | date | Incident date (default: today) |
shift | text | Lunch, Dinner, or Late Night |
notes | text | Free-form description (1–5000 chars) |
mediaUrls | text[] | Attachments (HEIC auto-converted before upload) |
resolutionNotes | text | Required when transitioning to resolved |
resolvedBy | text | FK → users.clerkId |
resolvedAt | timestamp | Set on resolve, cleared on reopen |
deferredReason | text | Required when transitioning to deferred |
deferredBy | text | FK → users.clerkId |
deferredAt | timestamp | Set on defer, cleared on move back to investigating |
contactId | text | Optional FK → contacts.id for the associated guest |
createdAt / updatedAt | timestamp | Auto-set / auto-updated |
Incident Comments (incident_comments)
| Column | Type | Description |
|---|---|---|
incidentId | text | FK → incidents.id (cascade) |
userId | text | The commenter's Clerk ID |
teamId | text | Tenant scope |
authorName | text | Cached display name (so deletes don't blank history) |
content | text | Comment text (1–2000 chars) |
createdAt | timestamp | Comment timestamp |
Incident Escalations (incident_escalations)
| Column | Type | Description |
|---|---|---|
incidentId | text | FK → incidents.id |
escalatedBy | text | FK → users.clerkId |
notes | text | Reason for the escalation (required) |
createdAt | timestamp | Escalation timestamp |
Status Workflow
Defined in apps/app/src/features/incidents/workflow.ts:
| From | Allowed to | Notes required? |
|---|---|---|
reported | investigating | No |
reported | deferred | Yes — deferral reason |
investigating | resolved | Yes — resolution notes |
investigating | deferred | Yes — reason |
deferred | investigating | No |
resolved | investigating | Yes — reopen reason (sets reopened = true) |
normalizeIncidentStatus translates the legacy in_progress value to investigating so older records still surface cleanly.
Permissions
apps/app/src/features/incidents/permissions.ts is the single source of truth for incident access control.
| Action | Rule |
|---|---|
canReadIncident | hasLocationAccess AND (admin, reporter, employee_visible, or isManagementAtLocation) |
canManageIncident | isManagementAtLocation (admin or manager at the location) |
canCommentOnIncident | canReadIncident (anyone with read access) |
canEscalateIncident | canManageIncident |
canPublishIncident | canManageIncident (controls visibility flips) |
canOwnIncidentAtLocation | admin/manager role AND the user is in the incident's location scope |
Visibility filtering is layered: getIncidents adds an OR clause for userId = self or (if member) visibility = employee_visible, so a non-management user only sees their own reports plus anything marked employee-visible.
Server Actions
All exported from apps/app/src/app/(authenticated)/incidents/actions.ts (split into crud.ts, status.ts, comments.ts, assignment.ts):
| Action | Auth | Purpose |
|---|---|---|
createIncident | member (rate-limited) | Validate, redact, persist, fan out notification |
getIncidents | member (keyset-paginated) | Cursor-based, location-scoped |
getIncidentById | member (visibility-checked) | Full detail incl. comments, escalations, source messages, state changes |
updateIncidentStatus | management | Transition with required notes; transactional audit log |
escalateIncident | management | Insert incident_escalations row, bump priority, urgent Slack notification |
addIncidentComment | member (read access) | Insert comment, audit-log incident.comment_added |
assignIncidentOwner | management | Set ownerUserId to any management user at the location |
updateIncidentVisibility | management | Toggle manager_only ↔ employee_visible |
updateIncidentPriority | management | Override the auto-derived priority |
Rate limits (limitIncidentMutation in actions/shared.ts): one mutation per incident per ~5 seconds, scoped by (action, locationId).
Notification Pipeline
createIncident, updateIncidentStatus, and escalateIncident enqueue jobs into the notification_queue table (defined in packages/database/src/schema/notifications.ts). The worker (/api/notifications + packages/notifications/incident.ts) processes the queue with:
- Slack —
buildIncidentBlocksfromapps/slack-bot/lib/templates/incident.tsposts to the location'sincidentsSlack channel. Includes Acknowledge / Escalate / View Details buttons. - Dedup — per
(incidentId, eventType, channel)keys. Repeated enqueues for the same event collapse to a single delivery.
The full set of event types: created, status-changed, escalated, reopened. addIncidentComment does not currently fan out (only the in-app record is added).
Audit Logging
Every incident mutation writes an audit row inside the same database transaction (runAuditedTransaction), so the log can never drift from the data:
| Action | When |
|---|---|
incident.created | On first submit |
incident.status_changed | On every status transition (with previousValue / newValue) |
incident.escalated | On every escalation, with the new priority |
incident.comment_added | On every comment |
incident.owner_changed | When ownership is reassigned |
incident.priority_changed | When the priority is manually overridden |
incident.visibility_changed | When the visibility flag is toggled |
The incident detail page reconstructs a "Status History" timeline from the incident.status_changed audit rows, surfacing reopen events with an ember-colored "Reopened" badge.
Source Messages
A message can be converted into an incident via createIncidentFromMessage (in apps/app/src/app/(authenticated)/messages/actions.ts). The new incident:
- Inherits the message's
mediaUrls, location, and audit author. - Gets its notes pre-populated with the original message body.
- Writes a
message_incident_linksrow (messageId,incidentId) so the cross-link survives deletion. - Auto-flips the message's
statetoin_progressif it wasnew.
The reverse view is in getIncidentById — it joins through messageIncidentLinks to list the source messages on the incident detail.