--- name: dyolink-notifications-inbox description: Header notification bell/inbox (UserNotification fan-out + Socket.IO). Use when changing inbox cards, realtime gateway, or notification deep links — distinct from sidebar tab badges. --- # Notifications inbox (bell) Permission-free **feature** (every dashboard user sees the bell). **Cards** are permission-filtered at fan-out time. ## vs tab badges | | Inbox (`UserNotification`) | Sidebar badges (`LabCaseActivity`) | |--|--|--| | Entry | Header bell → dropdown + `/notifications` | Sidebar Cases/Tasks/Treatment/Orgs | | Live | Socket.IO (`/realtime`) | Same socket → `notifyTabBadgesChanged()` → REST tab-counts | | Read | Per-card `readAt` only | Per-case / tab cursors | Do **not** clear tab badges when marking an inbox card read. ## Live cascade (one socket) On `notification.created`, [`RealtimeProvider`](frontend/src/lib/realtime/RealtimeProvider.tsx): 1. Updates inbox state (bell list + unread). 2. Calls `notifyTabBadgesChanged()` → Sidebar refetches `GET /notifications/tab-counts`. 3. For connection-request types, also `notifyPendingConnectionsChanged()`. 4. **Currently mounted** feature pages soft-refresh (no remount, no form wipe). Unmounted tabs do **not** fetch list data until opened: - **Cases** — silent list + selected detail reload - **Tasks** — silent task list reload - **Treatment** (`TreatmentWorkspace`, not thin `page.tsx`) — silent patient lab-cases + unread rail - **Organizations** — silent connections list on `pending-connections-changed` only Sidebar **badge counts** always refetch (hook lives in the always-mounted Sidebar). Treatment **draft/form state** is not cleared by this cascade. Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`. ## Backend - Model: `UserNotification` + `UserNotificationType` in Prisma - Fan-out: [`user-notification.service.ts`](backend/src/modules/notifications/user-notification.service.ts) - Realtime: [`backend/src/realtime/`](backend/src/realtime/) — `RealtimeGateway` (cookie JWT), `RealtimeEmitter`, rooms `user:{userId}:org:{organizationId}` - REST: `GET /notifications/inbox`, `GET /notifications/inbox/unread-count`, `POST /notifications/inbox/:id/read`, `POST /notifications/inbox/read-all` ## Emit sites (parallel to LabCaseActivity) CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites. **Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted. **Realtime auth:** after access-token refresh (proactive, axios 401 retry, or `checkAuth`), frontend dispatches `dyolink:access-token-refreshed` so `RealtimeProvider` reconnects with the new cookie. ## Frontend UI - `NotificationBell` + `NotificationsPage` + `NotificationCard` (full-width page list; same card height; extra context truncated on one line) - Deep links: `/cases?caseId=`, `/tasks?taskId=`, `/treatment?labCaseId=`, `/organizations`, `/staff`