Incident Reports & Emergency Alerts
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
This module covers two safety mechanisms:
- Incident reports — structured after-the-fact reports (falls, medication errors, injuries, etc.) that a care provider files against a client from the mobile app. Management reviews and resolves them on the web dashboard and can share them with the client's family. Backend:
apps/backend/src/incident-reports/. - Emergency alerts (SOS) — a real-time panic action available to the assigned caregiver during an in-progress shift. It immediately dials 911 on the device and, in parallel, records an alert with GPS coordinates and fans out critical notifications to management and family. Backend:
apps/backend/src/emergency-alerts/.
Both are tenant-scoped (every record carries a business ref) and both feed the unified notification pipeline (see 12-communication.md).
Entities & Data Model
IncidentReport
- Collection:
incident_reports(apps/backend/src/incident-reports/entities/incident-report.entity.ts:11) - Indexes:
{business:1, createdAt:-1},{client:1, createdAt:-1},{status:1}(entity file, lines 104–107), plus single-field indexes onbusinessandclient.
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed (tenant scope) |
client | ObjectId → Client | required, indexed |
reportedBy | ObjectId → User | required; the filing care provider |
incidentType | enum IncidentType | FALL, MEDICATION_ERROR, INJURY, BEHAVIORAL_INCIDENT, SKIN_INTEGRITY_ISSUE, CHOKING, MEDICAL_EMERGENCY (packages/shared/src/enums/domain-status.ts:718-726) |
incidentDate | Date | required; when the incident occurred |
description | String | required; min 10 chars (DTO create-incident-report.dto.ts:14-16) |
attachments | String[] | file URLs (photo uploads), default [] |
status | enum IncidentReportStatus | SUBMITTED → UNDER_REVIEW → RESOLVED (domain-status.ts:709-713); default SUBMITTED |
reviewedBy / reviewedAt / reviewNotes | ObjectId → User / Date / String | set when status moves to UNDER_REVIEW |
resolvedBy / resolvedAt / resolutionNotes | ObjectId → User / Date / String | set when status moves to RESOLVED |
sharedWithFamily / sharedWithFamilyAt | Boolean / Date | default false; one-way flag |
editableUntil | Date | required; creation time + 30 minutes (incident-reports.service.ts:35,63-64) |
createdAt / updatedAt | Date | Mongoose timestamps |
EmergencyAlert
- Collection:
emergency_alerts(apps/backend/src/emergency-alerts/entities/emergency-alert.entity.ts:6) - Indexes:
{business:1, triggeredAt:-1},{client:1, triggeredAt:-1}(entity file, lines 72–73).
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | required, indexed |
shiftAssignment | ObjectId → ShiftAssignment | required, indexed; the in-progress shift the SOS was raised from |
client | ObjectId → Client | required, indexed |
caregiver | ObjectId → User | required; the triggering caregiver |
latitude / longitude / accuracy | Number | optional GPS fix at trigger time |
triggeredAt | Date | required; device-supplied timestamp |
clientAddress | String | denormalized "street, city, state" snapshot (emergency-alerts.service.ts:88-92) |
The EmergencyAlert entity has no status field — it is a write-once event record with no acknowledgment or lifecycle in the schema.
Related records written by this module
- LocationBreadcrumb (
location_breadcrumbs, owned by the shifts module): when an emergency alert has coordinates and an open timesheet exists, a breadcrumb of typeLocationBreadcrumbType.EMERGENCY_CALLis written withmetadata.emergencyAlertId, so the SOS appears on the shift's location timeline/map (emergency-alerts.service.ts:115-135). - ShiftHandover (
shift-handoversmodule): incident submission enqueues an AI handover decision; if the AI decides a handover is warranted, aShiftHandoverdocument is created withsourceReportType: 'incident'andsourceReportId= the incident's id (apps/backend/src/shift-handovers/listeners/handover-trigger.listener.ts:51-59,shift-handovers.processor.ts).
Workflows & State Machines
Incident report: filing → review → resolution
- File (mobile) —
POST /clients/:clientId/incident-reportsrequiresINCIDENT_REPORTS_MANAGE(incident-reports.controller.ts:36-37). The service validates the client exists, stampseditableUntil = now + 30min, creates the report withstatus: SUBMITTED, then emitsincident-report.submittedand a platform-log activity event (incident-reports.service.ts:52-105). - Photo attachments —
POST .../:id/upload(reporter-only) uploads images viaFilesService.uploadFile({ isPublic: true, type: 'image', folder: 'incident-reports/{id}' })and appends the URL (incident-reports.service.ts:340-372). The mobile create screen uploads all selected photos right after creation (apps/mobile/app/(client)/[id]/incident-reports/create.tsx:64-83). - Edit window —
PATCH .../:idis allowed only for the reporter and only beforeeditableUntil; otherwise 403 (incident-reports.service.ts:210-219). Note: no client UI calls this endpoint today (noincidentReportApi.updateusage inapps/mobile/app/(client)/[id]/incident-reports/). - Review / resolve (web) —
PATCH .../:id/reviewaccepts onlyUNDER_REVIEWorRESOLVED(review-incident-report.dto.ts:5-6).UNDER_REVIEWstampsreviewedBy/reviewedAt(/reviewNotes);RESOLVEDstampsresolvedBy/resolvedAt(/resolutionNotes/reviewNotes). Emitsincident-report.status-changedplus a platform-log event (incident-reports.service.ts:230-298). - Share with family —
POST .../:id/shareflipssharedWithFamilyonce (409-styleBadRequestExceptionif already shared) and emitsincident-report.shared-with-family(incident-reports.service.ts:300-338).
The service performs no current-status transition validation — review() assigns whatever status the DTO carries (incident-reports.service.ts:243). The web UI constrains the flow (hides the action button once RESOLVED, defaults to "Start Review" from SUBMITTED and "Resolve" from UNDER_REVIEW), but the review dialog's status dropdown still lets a manager pick either value (apps/web/.../incident-reports/page.tsx, Detail/Review dialogs). This is a frontend-only rule.
Notification fan-out (incident reports)
All fan-out happens in apps/backend/src/notification/listeners/notification.listener.ts via NotificationService.sendUnifiedNotification → BullMQ unified-notification job → NotificationDispatcherService → per-channel send (in-app persists + emits over the notifications WebSocket via NotificationGateway; push via Expo; email/SMS only when the registry enables them). See 12-communication.md.
| Event | Recipients | Notification type & channels |
|---|---|---|
incident-report.submitted | All users whose effective role permissions include INCIDENT_REPORTS_VIEW_ALL (admins/care managers), excluding the submitter (notification.listener.ts:306-331; resolution in auth/services/permission.service.ts:119-160) | IncidentReportSubmitted — highPriority: in-app + high-priority push, no email/SMS; not user-configurable (notification-registry.ts:818-824) |
incident-report.status-changed | Same VIEW_ALL set plus the original reporter (notification.listener.ts:334-373) | IncidentReportUnderReview / IncidentReportResolved — standard: in-app + normal push; user-configurable (notification-registry.ts:825-838) |
incident-report.shared-with-family | Active RepresentativeProfile users for the client (notification.listener.ts:376-410) | IncidentReportSharedWithFamily — highPriority; not user-configurable (notification-registry.ts:839-845) |
Emergency alert: trigger → fan-out
- Trigger (mobile) — the SOS button is shown in the client-detail header only while the caregiver has an active shift (
apps/mobile/app/(client)/[id]/index.tsx:465-472). On confirm it dialstel:911immediately, then best-effort grabs GPS and callsPOST /shifts/:shiftId/emergency-alert; API failures are swallowed so the 911 call is never blocked (apps/mobile/components/emergency/sos-button.tsx:26-57). - Endpoint — guarded by
SHIFTS_EXECUTEand throttled to 3 requests/minute (apps/backend/src/shifts/shifts.controller.ts:708-721). - Validation — shift must exist, must be
IN_PROGRESS, and the caller must be the assigned caregiver (emergency-alerts.service.ts:66-81). - Persistence — creates the
EmergencyAlert(with denormalizedclientAddress) and, if coordinates and a timesheet exist, anEMERGENCY_CALLlocation breadcrumb on the shift timeline (emergency-alerts.service.ts:96-135). - Fan-out — emits
emergency-alert.triggered(events/emergency-alert.events.ts:1).
Notification fan-out (emergency alerts)
EmergencyAlertNotificationListener (apps/backend/src/notification/listeners/emergency-alert-notification.listener.ts:31-93) notifies:
- All active users in the business whose
roleis inMANAGEMENT_ROLES(=ADMIN_ROLES= Owner, Admin, SuperAdmin, System —packages/shared/src/enums/user-role.ts:41,53; note this is a deprecated role-group check, not a permission check), and - All active
RepresentativeProfile(family) users for the client, - minus the triggering caregiver.
Notification type EmergencyCallInitiated uses the critical channel set: in-app + critical-interruption push + email + SMS (Twilio via SmsChannel/SmsSenderService), not user-configurable (notification-registry.ts:847-855; channels at notification-registry.ts:8-34; SMS at apps/backend/src/notification/channels/sms.channel.ts). The in-app notification deep-links to /dashboard/clients/{clientId}/edit/resident-profile (emergency-alert-notification.listener.ts:79).
There is no acknowledgment workflow. No endpoint reads, lists, or acknowledges emergency_alerts — EmergencyAlertsService exposes only triggerEmergencyAlert and no controller exists in the module (emergency-alerts.module.ts registers no controllers). Acknowledgment-shaped permissions exist in the shared package but are wired to nothing (see Gaps).
Business Rules & Constraints
- Tenant scoping: every read/write filters by
business: businessIdfrom the JWT (incident-reports.service.ts:121-124,186,201,236,305,346). - Minimum description length: 10 characters, enforced by DTO validation (
create-incident-report.dto.ts:14-16) and duplicated client-side on mobile (create.tsx:61-62). - 30-minute edit window: only the reporter may
PATCHa report, and only untileditableUntil(incident-reports.service.ts:35,210-219). Attachment uploads are reporter-only but have no time-window check (incident-reports.service.ts:354-357). - Attachments are uploaded as public files (
isPublic: true,incident-reports.service.ts:360-363) and rendered on the web via direct<img src={url}>. - Review statuses limited by DTO: only
UNDER_REVIEWorRESOLVEDcan be set via/review; there is no way to return a report toSUBMITTED(review-incident-report.dto.ts:5-6). No current-status check is performed server-side (incident-reports.service.ts:243). - Share-with-family is one-way and idempotent-guarded: re-sharing throws (
incident-reports.service.ts:309-313). There is no "unshare". - PHI access auditing: both GET endpoints carry
@AuditPhiAccess(PlatformLogEntityType.INCIDENT_REPORT), which the platform-log interceptor turns into access-audit entries (incident-reports.controller.ts:52,66;apps/backend/src/common/decorators/audit-phi-access.decorator.ts). - Platform activity logs: submission, review, and resolution each emit
PLATFORM_LOG_ACTIVITY_EVENTwithINCIDENT_REPORT_SUBMITTED/INCIDENT_REPORT_REVIEWED/INCIDENT_REPORT_RESOLVEDactions (incident-reports.service.ts:92-104,281-297). - Emergency alert preconditions: shift
IN_PROGRESS+ caller is the assigned caregiver (emergency-alerts.service.ts:72-81); endpoint rate-limited to 3/minute (shifts.controller.ts:710). - Emergency alerts never block 911: the client dials first and treats the API call as best-effort (
sos-button.tsx:29-56). - Permissions (defaults in
packages/shared/src/constants/default-role-permissions.ts; businesses can override per-role and custom roles carry their own sets —permission.service.ts:43-84):- Admin:
INCIDENT_REPORTS_VIEW_ALL+MANAGE(lines 110–111);EMERGENCY_ALERTS_VIEW_ALL+ACKNOWLEDGE+CONFIGURE(114–116). - CareProvider:
INCIDENT_REPORTS_VIEW_OWN+MANAGE(224–225);EMERGENCY_ALERTS_VIEW_ASSIGNED+TRIGGER(228–229). - Representative (Family): no incident-report permissions;
EMERGENCY_ALERTS_VIEW_ASSIGNED+TRIGGER(292–293). - MedicalProfessional:
INCIDENT_REPORTS_VIEW_ASSIGNED(342);EMERGENCY_ALERTS_VIEW_ASSIGNED+ACKNOWLEDGE(345–346). - CareManager migration set:
INCIDENT_REPORTS_VIEW_ALL+MANAGE(421–422).
- Admin:
- Scope hierarchy:
:all≥:assigned≥:own(packages/shared/src/enums/business-permission.ts:286-336). GET endpoints requireINCIDENT_REPORTS_VIEW_ASSIGNED(incident-reports.controller.ts:53,67) —VIEW_OWNdoes not satisfy it (see Gaps #1). - "Assigned"/"own" scoping is not enforced in queries:
findAll/findOnefilter only by business + client; any caller who passes the guard sees every report for that client, regardless of assignment or authorship (incident-reports.service.ts:121-124,181-194). - Emergency alert permissions are not used by the endpoint: the trigger route checks
SHIFTS_EXECUTE, notEMERGENCY_ALERTS_TRIGGER(shifts.controller.ts:709).
Surfaces (Web & Mobile)
Mobile (care providers)
- Entry point: client detail screen → "Incident Report" care item (no role gating visible in the menu itself; backend permissions gate the API) (
apps/mobile/app/(client)/[id]/index.tsx:279-286). - List:
apps/mobile/app/(client)/[id]/incident-reports/index.tsx— paginated list with a create FAB. - Create:
apps/mobile/app/(client)/[id]/incident-reports/create.tsx— incident type picker (fromINCIDENT_TYPE_LABELS), date/time, description (client-side ≥10 chars), camera/gallery photos (gallery gated by upload policy,lib/upload-policy.tsimport at top) uploaded after creation. - Detail:
apps/mobile/app/(client)/[id]/incident-reports/[reportId].tsx— read-only view with attachments; no edit UI despite the backend's 30-minute edit window. - SOS button:
apps/mobile/components/emergency/sos-button.tsx, rendered in the client header only whenactiveShiftIdis set ((client)/[id]/index.tsx:465-472). Confirmation sheet → 911 dial + alert API. - API clients:
apps/mobile/lib/incident-report-api.ts,apps/mobile/lib/emergency-alert-api.ts.
Web (admins / care managers)
- Incident review page:
apps/web/app/(app)/(admin)/dashboard/clients/[id]/edit/incident-reports/page.tsx— status-tab filters with counts (from the API'sstatusCountsaggregation), report cards, a detail dialog (description, reporter, photos, review/resolution notes), "Share with Family", and a review dialog ("Start Review" / "Resolve" with notes). The empty state says "Incident reports created from the mobile app will appear here" — web has no create UI. Resolving also invalidates the client tracking summary (useInvalidateTrackingSummary). - Forms placeholder:
apps/web/app/(app)/(admin)/dashboard/forms/incident-report/page.tsxrenders aComingSooncomponent — a dead placeholder. - Emergency alerts on web: there is no alert list/inbox. Alerts surface only as (a) the
EmergencyCallInitiatednotification (toast/notification center, deep-link to the client's resident profile) and (b) an "Emergency 911 Call" pin on the shift location map/timeline via theEMERGENCY_CALLbreadcrumb (apps/web/.../shifts/_components/location-breadcrumb-utils.ts:30,40). - API client:
apps/web/lib/incident-report-api.ts(list/get/review/share; no create/update/upload on web).
Frontend-only rules
- Web hides review actions once
RESOLVEDand sequences SUBMITTED→UNDER_REVIEW→RESOLVED; the API allows any of the two DTO statuses at any time. - Mobile enforces the ≥10-char description before submitting (also enforced server-side).
- Mobile decides SOS availability purely from the local
activeShiftStore; the server re-validates shift status and assignment.
Cross-Module Dependencies
- incidents → shift-handovers (AI):
incident-report.submittedis consumed byHandoverTriggerListener, which queues a BullMQshift-handoversjob; an Anthropic-backed agent (ai-handover-decision.service.ts:1,82) decides whether to create aShiftHandoverattached to the client's in-progress (or ≤24h-old) shift, which the incoming caregiver acknowledges (handover-trigger.listener.ts:51-59,shift-handovers.processor.ts,shift-handovers.service.ts:204-260,shift-handover.entity.ts:8-11,95-101). See 05-scheduling-and-shifts.md. - incidents → notifications: three events handled in
notification.listener.ts:306-410; delivery via the unified dispatcher (in-app/WebSocket, Expo push; registrynotification-registry.ts:817-845). See 12-communication.md. - incidents → platform-logs: activity events on submit/review/resolve plus PHI read audits (
incident-reports.service.ts:92-104,281-297; controller decorators). - incidents → client tracking summary:
ClientTrackingSummaryServicecounts open (status != RESOLVED) and unreviewed (status == SUBMITTED) reports per client for the dashboard summary (apps/backend/src/clients/services/client-tracking-summary.service.ts:261-276). - incidents → shift summary exports (PDF):
ReportDataEnricherService.buildIncidentReportsaggregates incidents in a date range (including per-type counts andsharedWithFamily) into welfare/family PDF reports; visibility per audience is governed by theINCIDENT_REPORTScontent item matrix (apps/backend/src/clients/services/report-data-enricher.service.ts:209-237,packages/shared/src/types/shift-summary-export.ts:307,374,473,shift-summary-content-filter.service.ts:161).EMERGENCY_ALERTSis also a content item consumed by the PDF layouts (apps/backend/src/pdf/pdf-layout/report-formal-welfare.pdf.tsx:267). - emergency-alerts → shifts: alerts are created from a shift, validate against
ShiftAssignment/Timesheet, and writeLocationBreadcrumbrecords (emergency-alerts.module.ts,emergency-alerts.service.ts:96-135). See 05-scheduling-and-shifts.md. - emergency-alerts → notifications: dedicated listener with email + SMS escalation (
emergency-alert-notification.listener.ts). See 12-communication.md.
Pain point: connection to schedule and care plan
Filing an incident report does not touch the client's care plan, schedule, assessments, or any change-of-conditions record. Verified by tracing every emitter/listener out of incident-reports/:
- The module emits exactly four things:
incident-report.submitted,incident-report.status-changed,incident-report.shared-with-family, and platform-log events (incident-reports.service.ts). Their only listeners are the notification listeners (notification.listener.ts:306-410) and the handover trigger (handover-trigger.listener.ts:51). No listener exists inclients/(care plans),client-medications/,initial-assessments/, or scheduling services — a repo-wide grep for these event names finds no other consumers. - The single behavioral cascade is the AI shift-handover: an incident may generate a
ShiftHandovernote that the next caregiver must acknowledge at login (shift-handovers.processor.ts,shift-handover.entity.ts). This informs the next shift but does not modify the schedule, shift tasks, or the care plan document (apps/backend/src/clients/entities/client-care-plan.entity.tshas no incident linkage). - The change-of-conditions flow (
health-observationsmodule,sourceReportType: 'coc') is a parallel sibling input to the same handover queue — incidents do not create or escalate into a COC record, and nothing converts a resolved incident into a care-plan revision or reassessment. See 02-assessments.md and 04-care-plans-and-tasks.md. - Remaining surfaces are read-only roll-ups: dashboard counters (
client-tracking-summary.service.ts:261-276) and PDF report sections (report-data-enricher.service.ts:209+).
Bottom line: after review/resolution, an incident report is a dead end. The handover banner is the only operational ripple; care plans, schedules, and assessments are never updated or even flagged for follow-up from this module. This is a key product gap (Gaps #7).
Open Questions & Gaps
- Default care providers may be unable to read incident reports. GET list/detail require
INCIDENT_REPORTS_VIEW_ASSIGNED(incident-reports.controller.ts:53,67), but the CareProvider default set contains onlyVIEW_OWN+MANAGE(default-role-permissions.ts:224-225), andowndoes not satisfyassignedin the scope hierarchy (business-permission.ts:317-336). As written, a default-permission care provider can create reports but gets 403 listing/viewing them — yet the mobile app ships full list/detail screens for them. Whether production businesses rely onbaseRolePermissionOverridesto fix this cannot be determined from code. INCIDENT_REPORTS_VIEW_OWNis enforced nowhere in the backend, and thefindAllquery implements noown/assignedscoping — anyone passing the guard sees all reports for the client (incident-reports.service.ts:121-124). The scope labels inpermission-groups.ts:160-162over-promise relative to actual behavior.- No status-transition validation.
review()writes the DTO status directly (incident-reports.service.ts:243), allowing SUBMITTED→RESOLVED in one step (intentional, per web UI) and RESOLVED→UNDER_REVIEW re-opening (web hides it; the API does not). A re-opened report keeps its staleresolvedBy/resolvedAtfields. - Family cannot actually view a shared report. "Share with family" sets a flag and notifies active representatives with the report id in metadata (
notification.listener.ts:376-410), but Representatives have no incident-report permission by default (default-role-permissions.ts:260-322contains none) and no family-facing endpoint filters bysharedWithFamily. The only place the flag is consumed is the PDF export enricher (report-data-enricher.service.ts:237). What the family is supposed to see after tapping the notification cannot be determined from code. - Emergency alerts are write-only. No list/read/acknowledge/configure API exists despite
EMERGENCY_ALERTS_VIEW_ASSIGNED/VIEW_ALL/ACKNOWLEDGE/CONFIGUREbeing defined, granted by default, and even surfaced in the permission-management UI strings ("Acknowledge and respond to emergency alerts",permission-groups.ts:404-406). The trigger endpoint also checksSHIFTS_EXECUTEinstead ofEMERGENCY_ALERTS_TRIGGER(shifts.controller.ts:709), so the grantedTRIGGERpermission (including the Representative's, line 292–293) is inert. Admins can never mark an alert handled, and there is no escalation if nobody responds. - Incident photo attachments are uploaded as public files (
isPublic: true,incident-reports.service.ts:361) even though incident GETs are treated as PHI access (audit decorator). Anyone with the URL can fetch the image. Also, attachment upload has no edit-window or count/size limit, unlike report edits. - Incidents are operationally a dead end (the cited pain point): no care-plan revision, no reassessment trigger, no schedule adjustment, no COC escalation follows from filing or resolving an incident — only notifications, an optional AI handover note, dashboard counters, and PDF roll-ups. See the pain-point subsection above.
- The 30-minute edit window is unused by clients: no web or mobile UI calls
PATCH .../:id(noincidentReportApi.updatecallers), so reporters have no practical way to correct a report; and because the service restrictsupdateto the reporter, admins holdingMANAGEcannot correct one either. There is also no delete endpoint. - Inconsistent recipient selection between the two features: incident fan-out is permission-based (
getUserIdsWithPermissionwithVIEW_ALL), while emergency fan-out uses the deprecatedMANAGEMENT_ROLESrole-group (emergency-alert-notification.listener.ts:42-46; deprecation note atuser-role.ts:46-53) — custom roles with full emergency permissions would receive incident notifications but not emergency alerts. - Web "Forms → Incident Report" page is a ComingSoon stub (
apps/web/app/(app)/(admin)/dashboard/forms/incident-report/page.tsx) — intended scope cannot be determined from code. - Emergency alert timestamps are device-supplied (
triggeredAtcomes from the client DTO with no server-side sanity check,emergency-alerts.service.ts:93), so a skewed device clock produces misleading alert/breadcrumb timelines.