Compare commits

...

3 Commits

56 changed files with 2241 additions and 285 deletions

View File

@@ -37,3 +37,7 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo
## Lab case share link
QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`.
## Notifications (inbox + live tabs)
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.

View File

@@ -1,14 +0,0 @@
---
description: Lab tab badges — activity model, tab-counts API, read cursors
globs: backend/src/modules/notifications/**,backend/src/common/lab-case-activity.ts,frontend/src/lib/hooks/useTabBadgeCounts.ts,frontend/src/lib/tabBadgeUtils.ts,frontend/src/lib/api/notifications.ts,frontend/src/components/ui/shared/NavBadgePill.tsx,frontend/src/components/ui/shared/Sidebar.tsx
alwaysApply: false
---
# Lab tab badges
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments; Clinic Treatment = visible lab comments + completions.
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Orgs connections badge** stays on separate `pending-count` endpoint.
Full map: `.cursor/skills/lab-notifications/SKILL.md`

View File

@@ -0,0 +1,16 @@
---
description: Sidebar tab badges (Cases/Tasks/Treatment) — activity model, tab-counts API, read cursors
globs: backend/src/modules/notifications/**,backend/src/common/lab-case-activity.ts,frontend/src/lib/hooks/useTabBadgeCounts.ts,frontend/src/lib/tabBadgeUtils.ts,frontend/src/lib/api/notifications.ts,frontend/src/components/ui/shared/NavBadgePill.tsx,frontend/src/components/ui/shared/Sidebar.tsx
alwaysApply: false
---
# Tab badges (Cases / Tasks / Treatment)
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + completions.
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).
- **Orgs connections badge** stays on separate `pending-count` endpoint.
- **Header inbox** (bell) is separate — `.cursor/skills/notifications-inbox/SKILL.md`.
Full map: `.cursor/skills/tab-badges/SKILL.md`

View File

@@ -40,6 +40,10 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
## Live soft refresh
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadCases` + selected `loadDetail` (keeps filters/selection; no full remount). Same event refreshes sidebar Cases badge via `useTabBadgeCounts`. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Permissions
`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes.

View File

@@ -91,9 +91,11 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
- **Tasks filters:** filter `<select>`s use the same 44px mobile height as other form controls.
- **Treatment lab dispatch:** shipment card `p-3 sm:p-4`; **Send to lab** is `w-full sm:w-auto`.
## Tab badges
## Tab badges + live soft refresh
See `.cursor/skills/lab-notifications/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.
## Permissions

View File

@@ -0,0 +1,55 @@
---
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`

View File

@@ -1,9 +1,11 @@
---
name: dyolink-lab-notifications
description: Lab case activity feed + sidebar tab badge counts. Use when changing notifications API, LabCaseActivity, read state, or Sidebar badges for Cases/Tasks/Treatment.
name: dyolink-tab-badges
description: Sidebar tab badge counts (Cases/Tasks/Treatment) from LabCaseActivity + read cursors. Use when changing tab-counts API, activity emit, read state, or Sidebar badges — distinct from the header inbox bell.
---
# Lab notifications (tab badges)
# Tab badges (Cases / Tasks / Treatment)
Sidebar unread pills for **lab Cases**, **lab Tasks**, and **clinic Treatment**. Activity is stored as `LabCaseActivity` (shared clinic↔lab case events); badges are org-type-specific.
Backend: [`backend/src/modules/notifications/`](backend/src/modules/notifications/)
Activity types: [`backend/src/common/lab-case-activity.ts`](backend/src/common/lab-case-activity.ts)
@@ -11,16 +13,16 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
## Models
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED`
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
## Tab badge buckets (Option B — split lab counts)
## Tab badge buckets (Option B)
| Org | Tab | Activity types |
|-----|-----|----------------|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED`**only lab cases for treatments the user provided** |
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
@@ -42,17 +44,31 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
| Comment | `lab-case-comments.service``CLINIC_COMMENT` / `LAB_COMMENT` |
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
| Task completed | `tasks.service` `updateStatus``TASK_COMPLETED` |
| Task assigned | `cases.service` `assignTask``TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
**Live path:** inbox Socket.IO `notification.created` also dispatches that event so sidebar counts refresh without navigation. **Only currently mounted** feature pages soft-reload list data on the same event (silent; Treatment form/draft untouched). Unmounted tabs do not fetch list data until the user opens them. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Frontend pattern (same as org connections)
- `useTabBadgeCounts()` — fetch on pathname change + `tab-badges-changed` event
- `useTabBadgeCounts()` always mounted in dashboard Sidebar; fetch on pathname change + `tab-badges-changed`
- `useMarkTabReadOnVisit()` — Tasks page only (Cases/Treatment badges clear when opening unread cases)
- `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx)
- **Organizations** pending connections still use `usePendingConnectionsCount` (separate pending-state API)
- **Live soft refresh (mounted page only):**
- Cases → silent list + selected detail
- Tasks → silent task list
- Treatment (`TreatmentWorkspace`) → silent patient lab cases + unread rail
- Orgs → silent list on `pending-connections-changed` only
- **TASK_ASSIGNED** Tasks badge is assignee-scoped (`payload.assigneeUserId`); other Tasks-bucket events stay org-wide for users with tab access.
## Out of scope (later steps)
- Push / email / websockets
- Push / email
- Prefetching unmounted tab list data on every socket event
- Pushing full page remounts / wiping Treatment draft state on live events
- `CASE_AMENDED` emit (Step 7)
## Related: header inbox
Permission-free bell + `UserNotification` fan-out + Socket.IO — see `.cursor/skills/notifications-inbox/SKILL.md`. Independent of tab badge cursors.

View File

@@ -131,7 +131,9 @@ Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share),
- UI: `DetailLabCaseCommentsSection``LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
## Live lab rail refresh
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent refresh of patient lab cases + unread rail. Does **not** remount the workspace or clear draft/form state. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Appointments default selection

View File

@@ -55,13 +55,14 @@ frontend/src/
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the entry wizard **Lab** step.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab case share link (quick ref):**
- Token on first ship → `/{locale}/lab-case/{token}` after login.
@@ -96,7 +97,8 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment |
| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules |
| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/tab-badges/` | Sidebar tab badges: Cases/Tasks/Treatment, LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime |
| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |

View File

@@ -20,8 +20,10 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.28",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.2",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
@@ -41,10 +43,9 @@
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"prisma": "^6.19.2",
"qrcode": "^1.5.4",
"react-qr-code": "^2.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"styled-components": "^6.3.11",
"swagger-ui-express": "^5.0.1"
},
@@ -61,9 +62,6 @@
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
@@ -4485,6 +4483,25 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "11.1.28",
"resolved": "https://registry.npmmirror.com/@nestjs/platform-socket.io/-/platform-socket.io-11.1.28.tgz",
"integrity": "sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -4580,6 +4597,29 @@
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/websockets": {
"version": "11.1.28",
"resolved": "https://registry.npmmirror.com/@nestjs/websockets/-/websockets-11.1.28.tgz",
"integrity": "sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==",
"license": "MIT",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -5320,6 +5360,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -6088,6 +6134,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -6293,16 +6348,6 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -6325,16 +6370,6 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
@@ -6424,6 +6459,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -7553,6 +7597,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7562,6 +7607,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -7893,6 +7939,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
@@ -8174,6 +8229,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -8451,6 +8507,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -8463,6 +8520,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -8785,15 +8843,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -8907,12 +8956,6 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -9031,6 +9074,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
@@ -9051,6 +9095,79 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.9",
"resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
@@ -10074,6 +10191,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -10691,6 +10809,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12646,6 +12765,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -12895,6 +13023,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13208,15 +13337,6 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
@@ -13635,133 +13755,6 @@
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -13971,19 +13964,6 @@
"react-dom": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-qr-code": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz",
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qrcode-generator": "^2.0.4"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-redux": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
@@ -14209,6 +14189,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14224,12 +14205,6 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -14514,12 +14489,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -14664,6 +14633,90 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.8",
"resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.21.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -14826,6 +14879,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -14856,6 +14910,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -16346,12 +16401,6 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16373,6 +16422,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -16422,6 +16472,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",

View File

@@ -41,8 +41,10 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.28",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.2",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
@@ -64,6 +66,7 @@
"prisma": "^6.19.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"styled-components": "^6.3.11",
"swagger-ui-express": "^5.0.1"
},

View File

@@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "UserNotificationType" AS ENUM (
'CASE_SENT',
'CLINIC_COMMENT',
'LAB_COMMENT',
'LAB_COMMENT_CLINIC',
'CASE_IMPORTANT',
'TASK_COMPLETED',
'TASK_ASSIGNED',
'CONNECTION_REQUEST',
'STAFF_INVITE'
);
-- CreateTable
CREATE TABLE "user_notifications" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"type" "UserNotificationType" NOT NULL,
"actorUserId" TEXT,
"payload" JSONB,
"href" TEXT NOT NULL,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_notifications_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "user_notifications_userId_organizationId_createdAt_idx"
ON "user_notifications"("userId", "organizationId", "createdAt");
CREATE INDEX "user_notifications_userId_organizationId_readAt_idx"
ON "user_notifications"("userId", "organizationId", "readAt");
ALTER TABLE "user_notifications"
ADD CONSTRAINT "user_notifications_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_notifications"
ADD CONSTRAINT "user_notifications_actorUserId_fkey"
FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED';

View File

@@ -30,6 +30,8 @@ model User {
labCaseComments LabCaseComment[]
labCaseActivities LabCaseActivity[]
phoneVerificationCodes PhoneVerificationCode[]
userNotifications UserNotification[]
actedUserNotifications UserNotification[] @relation("UserNotificationActor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -427,6 +429,7 @@ enum LabCaseActivityType {
CASE_IMPORTANT
CASE_AMENDED
TASK_COMPLETED
TASK_ASSIGNED
}
enum LabCaseTabReadTarget {
@@ -435,6 +438,38 @@ enum LabCaseTabReadTarget {
TREATMENT
}
enum UserNotificationType {
CASE_SENT
CLINIC_COMMENT
LAB_COMMENT
LAB_COMMENT_CLINIC
CASE_IMPORTANT
TASK_COMPLETED
TASK_ASSIGNED
CONNECTION_REQUEST
STAFF_INVITE
}
/// Fan-out inbox row per recipient. Independent of LabCaseActivity tab badges.
model UserNotification {
id String @id @default(uuid())
userId String
organizationId String
type UserNotificationType
actorUserId String?
payload Json?
href String
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
actorUser User? @relation("UserNotificationActor", fields: [actorUserId], references: [id], onDelete: SetNull)
@@index([userId, organizationId, createdAt])
@@index([userId, organizationId, readAt])
@@map("user_notifications")
}
model LabCaseActivity {
id String @id @default(uuid())
labCaseId String

View File

@@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
import { TodayModule } from './modules/today/today.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { RealtimeModule } from './realtime/realtime.module';
@Module({
imports: [
@@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
OrganizationModule,
TodayModule,
NotificationsModule,
RealtimeModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -7,10 +7,11 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.CASE_IMPORTANT,
];
/** Lab Tasks tab — task completions and lab-side comments. */
/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_ASSIGNED,
];
/** Clinic Treatment tab — visible lab comments and task progress. */

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
import { LabCaseAccessService } from './lab-case-access.service';
const labCaseListInclude = {
@@ -96,6 +97,7 @@ export class CasesService {
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
private readonly labCaseAccess: LabCaseAccessService,
) {}
@@ -385,6 +387,14 @@ export class CasesService {
type: LabCaseActivityType.CASE_IMPORTANT,
actorUserId,
});
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.CASE_IMPORTANT,
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId },
requiredPermission: 'TAB_CASES_READ',
});
}
const labCase = await this.prisma.labCase.findFirstOrThrow({
@@ -476,6 +486,24 @@ export class CasesService {
},
});
if (assigneeUserId) {
await this.labCaseActivity.record({
labCaseId,
type: LabCaseActivityType.TASK_ASSIGNED,
actorUserId,
payload: { taskId, assigneeUserId },
});
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_ASSIGNED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId, taskId, assigneeUserId },
recipientUserIds: [assigneeUserId],
});
}
const labCase = await this.prisma.labCase.findFirstOrThrow({
where: { id: labCaseId },
include: labCaseListInclude,

View File

@@ -3,12 +3,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
@@ -24,6 +25,7 @@ export class LabCaseCommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
// ---------- Lab side (TAB_TASKS_EDIT) ----------
@@ -67,6 +69,31 @@ export class LabCaseCommentsService {
visibleToClinic: created.visibleToClinic,
},
});
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.LAB_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_TASKS_READ',
});
if (created.visibleToClinic) {
const clinicOrgId = await this.clinicOrgIdForCase(caseId);
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.LAB_COMMENT_CLINIC,
href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: caseId,
});
}
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
}
@@ -130,6 +157,17 @@ export class LabCaseCommentsService {
actorUserId,
payload: { commentId: created.id },
});
const labOrgId = await this.labOrgIdForCase(caseId);
if (labOrgId) {
void this.userNotifications.notify({
organizationId: labOrgId,
type: UserNotificationType.CLINIC_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_CASES_READ',
});
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
@@ -172,6 +210,17 @@ export class LabCaseCommentsService {
actorUserId,
payload: { commentId: created.id },
});
const labOrgId = await this.labOrgIdForCase(caseId);
if (labOrgId) {
void this.userNotifications.notify({
organizationId: labOrgId,
type: UserNotificationType.CLINIC_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_CASES_READ',
});
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
@@ -336,4 +385,21 @@ export class LabCaseCommentsService {
}
throw new ForbiddenException('You do not have access to treatment cases');
}
private async labOrgIdForCase(caseId: string): Promise<string | null> {
const send = await this.prisma.labCaseSend.findFirst({
where: { labCaseId: caseId },
orderBy: { sentAt: 'asc' },
select: { organizationId: true },
});
return send?.organizationId ?? null;
}
private async clinicOrgIdForCase(caseId: string): Promise<string | null> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: caseId },
select: { treatment: { select: { organizationId: true } } },
});
return labCase?.treatment.organizationId ?? null;
}
}

View File

@@ -1,4 +1,5 @@
import { IsEnum, IsUUID } from 'class-validator';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { LabCaseTabReadTarget } from '@prisma/client';
export class MarkTabReadDto {
@@ -10,3 +11,16 @@ export class MarkCaseReadDto {
@IsUUID()
labCaseId!: string;
}
export class ListInboxQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number;
@IsOptional()
@IsString()
cursor?: string;
}

View File

@@ -318,7 +318,10 @@ export class LabCaseActivityService {
const commentId = payload?.commentId;
if (typeof commentId === 'string') commentIds.push(commentId);
}
if (activity.type === LabCaseActivityType.TASK_COMPLETED) {
if (
activity.type === LabCaseActivityType.TASK_COMPLETED ||
activity.type === LabCaseActivityType.TASK_ASSIGNED
) {
const taskId = payload?.taskId;
if (typeof taskId === 'string') taskIds.push(taskId);
}
@@ -387,18 +390,44 @@ export class LabCaseActivityService {
};
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
const includesTaskAssigned = types.includes(LabCaseActivityType.TASK_ASSIGNED);
const otherTypes = types.filter((type) => type !== LabCaseActivityType.TASK_ASSIGNED);
// TASK_ASSIGNED: count for the assignee only (including self-assign).
// Other task-tab types: org-wide, excluding events the current user authored.
const typeFilter: Prisma.LabCaseActivityWhereInput = includesTaskAssigned
? {
OR: [
...(otherTypes.length > 0
? [
{
AND: [
{ type: { in: otherTypes } },
{
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
},
],
},
]
: []),
{
type: LabCaseActivityType.TASK_ASSIGNED,
payload: { path: ['assigneeUserId'], equals: userId },
},
],
}
: {
AND: [
{ type: { in: types } },
{ OR: [{ actorUserId: null }, { actorUserId: { not: userId } }] },
],
};
return this.prisma.labCaseActivity.count({
where: {
type: { in: types },
createdAt: { gt: since },
labCase: labCaseScope,
AND: [
{
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
},
clinicLabCommentFilter,
],
AND: [clinicLabCommentFilter, typeFilter],
},
});
}

View File

@@ -1,15 +1,34 @@
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto';
import {
ListInboxQueryDto,
MarkCaseReadDto,
MarkTabReadDto,
} from './dto/notifications.dto';
import { LabCaseActivityService } from './lab-case-activity.service';
import { UserNotificationService } from './user-notification.service';
@ApiTags('notifications')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
constructor(
private readonly labCaseActivityService: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
@Get('tab-counts')
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
@@ -69,4 +88,53 @@ export class NotificationsController {
dto.labCaseId,
);
}
@Get('inbox')
@ApiOperation({ summary: 'Paginated notification inbox for the current user and org' })
listInbox(
@Query() query: ListInboxQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: { items: [], nextCursor: null } };
}
return this.userNotifications.listInbox(req.user.id, organizationId, {
limit: query.limit,
cursor: query.cursor,
});
}
@Get('inbox/unread-count')
@ApiOperation({ summary: 'Unread inbox count for the header bell' })
inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: { count: 0 } };
}
return this.userNotifications.unreadCount(req.user.id, organizationId);
}
@Post('inbox/read-all')
@ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' })
markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true };
}
return this.userNotifications.markAllRead(req.user.id, organizationId);
}
@Post('inbox/:id/read')
@ApiOperation({ summary: 'Mark a single inbox notification as read' })
markInboxRead(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: null };
}
return this.userNotifications.markRead(req.user.id, organizationId, id);
}
}

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { LabCaseActivityService } from './lab-case-activity.service';
import { NotificationsController } from './notifications.controller';
import { UserNotificationService } from './user-notification.service';
import { RealtimeModule } from '../../realtime/realtime.module';
@Module({
imports: [RealtimeModule],
controllers: [NotificationsController],
providers: [LabCaseActivityService],
exports: [LabCaseActivityService],
providers: [LabCaseActivityService, UserNotificationService],
exports: [LabCaseActivityService, UserNotificationService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,350 @@
import { Injectable } from '@nestjs/common';
import { Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { RealtimeEmitter } from '../../realtime/realtime.emitter';
const membershipInclude = {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
} satisfies Prisma.MembershipInclude;
export type UserNotificationDto = {
id: string;
type: UserNotificationType;
href: string;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
actorUserId: string | null;
};
type FanoutInput = {
organizationId: string;
type: UserNotificationType;
href: string;
actorUserId?: string | null;
payload?: Record<string, unknown> | null;
/** Explicit recipient user ids (e.g. task assignee). Skips permission fan-out. */
recipientUserIds?: string[];
/** Required tab permission when resolving org members. */
requiredPermission?: string;
/** When set, only clinic members who are the treatment provider for this case. */
labCaseIdForProviderScope?: string;
};
@Injectable()
export class UserNotificationService {
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeEmitter,
) {}
/**
* Lean lab-case snapshot for inbox cards. Used only inside `notify()` so
* inbox list/read never joins patients/orgs/tasks.
*/
private async labCaseInboxPayload(
labCaseId: string,
extra?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: labCaseId },
select: {
id: true,
destinationOrganizationId: true,
treatment: {
select: {
organization: { select: { name: true } },
patient: { select: { firstName: true, lastName: true } },
},
},
toothProsthesis: { select: { prosthesisTypeCode: true } },
tasks: { select: { prosthesisTypeCode: true }, take: 40 },
sends: {
orderBy: { sentAt: 'asc' },
take: 1,
select: { organization: { select: { name: true } } },
},
},
});
if (!labCase?.treatment) {
return { labCaseId, ...(extra ?? {}) };
}
let labName: string | null = labCase.sends[0]?.organization.name ?? null;
if (!labName && labCase.destinationOrganizationId) {
const dest = await this.prisma.organization.findUnique({
where: { id: labCase.destinationOrganizationId },
select: { name: true },
});
labName = dest?.name ?? null;
}
const prosthesisTypeCodes = [
...new Set(
[
...labCase.toothProsthesis.map((row) => row.prosthesisTypeCode),
...labCase.tasks.map((row) => row.prosthesisTypeCode),
].filter(Boolean),
),
];
const patient = labCase.treatment.patient;
const patientName = `${patient.firstName} ${patient.lastName}`.trim();
// Caller ids (taskId, commentId, …) come from `extra`; denormalized display
// fields must win so they are never overwritten by a thin emit payload.
return {
...(extra ?? {}),
labCaseId,
patientName,
clinicName: labCase.treatment.organization.name,
labName,
prosthesisTypeCodes,
};
}
/** Denormalize display fields into payload once at write time. */
private async enrichInboxPayload(
input: FanoutInput,
): Promise<Record<string, unknown> | null> {
const base =
input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload)
? { ...(input.payload as Record<string, unknown>) }
: {};
try {
const labCaseId =
(typeof base.labCaseId === 'string' && base.labCaseId) ||
input.labCaseIdForProviderScope ||
null;
let enriched: Record<string, unknown> = { ...base };
if (labCaseId) {
enriched = await this.labCaseInboxPayload(labCaseId, enriched);
}
const taskId = typeof enriched.taskId === 'string' ? enriched.taskId : null;
if (taskId && typeof enriched.taskName !== 'string') {
const task = await this.prisma.labCaseTask.findUnique({
where: { id: taskId },
select: { stepLabel: true, prosthesisTypeCode: true },
});
if (task) {
enriched = {
...enriched,
taskName: task.stepLabel,
prosthesisTypeCode: task.prosthesisTypeCode,
};
}
}
const fromOrganizationId =
typeof enriched.fromOrganizationId === 'string' ? enriched.fromOrganizationId : null;
if (fromOrganizationId && typeof enriched.fromOrganizationName !== 'string') {
const org = await this.prisma.organization.findUnique({
where: { id: fromOrganizationId },
select: { name: true },
});
if (org) {
enriched = { ...enriched, fromOrganizationName: org.name };
}
}
const membershipId =
typeof enriched.membershipId === 'string' ? enriched.membershipId : null;
if (membershipId && typeof enriched.inviteeName !== 'string') {
const membership = await this.prisma.membership.findUnique({
where: { id: membershipId },
select: { user: { select: { name: true, email: true } } },
});
if (membership?.user) {
enriched = {
...enriched,
inviteeName: membership.user.name,
email:
typeof enriched.email === 'string' && enriched.email
? enriched.email
: membership.user.email,
};
}
}
return Object.keys(enriched).length > 0 ? enriched : null;
} catch {
// Never block inbox write if enrichment fails — store the thin payload.
return Object.keys(base).length > 0 ? base : null;
}
}
async notify(input: FanoutInput): Promise<void> {
// Explicit recipients keep the actor (e.g. self-assign). Fan-out still skips the actor.
const recipientIds = input.recipientUserIds?.length
? [...new Set(input.recipientUserIds.filter((id): id is string => Boolean(id)))]
: await this.resolveRecipients(input);
if (recipientIds.length === 0) return;
const payload = await this.enrichInboxPayload(input);
const rows = await this.prisma.userNotification.createManyAndReturn({
data: recipientIds.map((userId) => ({
userId,
organizationId: input.organizationId,
type: input.type,
actorUserId: input.actorUserId ?? null,
payload: (payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
href: input.href,
})),
});
for (const row of rows) {
const dto = this.mapRow(row);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.created', {
notification: dto,
});
const unreadCount = await this.countUnread(row.userId, row.organizationId);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.unreadCount', {
count: unreadCount,
});
}
}
async listInbox(
userId: string,
organizationId: string,
options?: { limit?: number; cursor?: string },
) {
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 50);
const rows = await this.prisma.userNotification.findMany({
where: { userId, organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
...(options?.cursor
? {
cursor: { id: options.cursor },
skip: 1,
}
: {}),
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
return {
success: true as const,
data: {
items: page.map((row) => this.mapRow(row)),
nextCursor,
},
};
}
async unreadCount(userId: string, organizationId: string) {
const count = await this.countUnread(userId, organizationId);
return { success: true as const, data: { count } };
}
async markRead(userId: string, organizationId: string, notificationId: string) {
const existing = await this.prisma.userNotification.findFirst({
where: { id: notificationId, userId, organizationId },
});
if (!existing) {
return { success: true as const, data: null };
}
if (!existing.readAt) {
const updated = await this.prisma.userNotification.update({
where: { id: notificationId },
data: { readAt: new Date() },
});
const count = await this.countUnread(userId, organizationId);
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count,
});
return { success: true as const, data: this.mapRow(updated) };
}
return { success: true as const, data: this.mapRow(existing) };
}
async markAllRead(userId: string, organizationId: string) {
await this.prisma.userNotification.updateMany({
where: { userId, organizationId, readAt: null },
data: { readAt: new Date() },
});
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count: 0,
});
return { success: true as const };
}
private async countUnread(userId: string, organizationId: string): Promise<number> {
return this.prisma.userNotification.count({
where: { userId, organizationId, readAt: null },
});
}
private mapRow(row: {
id: string;
type: UserNotificationType;
href: string;
payload: Prisma.JsonValue;
readAt: Date | null;
createdAt: Date;
actorUserId: string | null;
}): UserNotificationDto {
return {
id: row.id,
type: row.type,
href: row.href,
payload:
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>)
: null,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
actorUserId: row.actorUserId,
};
}
private async resolveRecipients(input: FanoutInput): Promise<string[]> {
if (!input.requiredPermission) return [];
const memberships = await this.prisma.membership.findMany({
where: {
organizationId: input.organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: membershipInclude,
});
let userIds = memberships
.filter((m) => hasEffectivePermission(m, input.requiredPermission!))
.map((m) => m.userId)
.filter((id) => id !== input.actorUserId);
if (input.labCaseIdForProviderScope) {
const labCase = await this.prisma.labCase.findUnique({
where: { id: input.labCaseIdForProviderScope },
select: {
treatment: {
select: {
providerUserId: true,
appointment: { select: { providerUserId: true } },
},
},
},
});
if (!labCase?.treatment) return [];
userIds = userIds.filter((userId) =>
isActorTreatmentProvider(labCase.treatment, userId),
);
}
return [...new Set(userIds)];
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
imports: [CasesModule, LabCaseCommentsModule],
imports: [CasesModule, LabCaseCommentsModule, NotificationsModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})

View File

@@ -5,7 +5,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus } from '@prisma/client';
import { LinkStatus, UserNotificationType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
import { UserNotificationService } from '../notifications/user-notification.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -36,6 +37,7 @@ export class OrganizationService {
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
private readonly commentsService: LabCaseCommentsService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -269,6 +271,15 @@ export class OrganizationService {
},
});
void this.userNotifications.notify({
organizationId: dto.targetOrganizationId,
type: UserNotificationType.CONNECTION_REQUEST,
href: '/organizations',
actorUserId: userId,
payload: { organizationLinkId: created.id, fromOrganizationId: organizationId },
requiredPermission: 'TAB_ORGANIZATIONS_READ',
});
return {
success: true,
data: { id: created.id, status: created.status },

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { StaffController } from './staff.controller';
import { StaffService } from './staff.service';
import { StaffWorkingHoursService } from './staff-working-hours.service';
@Module({
imports: [NotificationsModule],
controllers: [StaffController],
providers: [StaffService, StaffWorkingHoursService, PrismaService],
exports: [StaffWorkingHoursService],

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -17,10 +17,14 @@ import {
} from '../../common/organization-type';
import { InviteStaffDto } from './dto/invite-staff.dto';
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
import { UserNotificationService } from '../notifications/user-notification.service';
@Injectable()
export class StaffService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -217,6 +221,19 @@ export class StaffService {
};
});
void this.userNotifications.notify({
organizationId,
type: UserNotificationType.STAFF_INVITE,
href: '/staff',
actorUserId: userId,
payload: {
membershipId: result.membershipId,
staffInvitationId: result.invitationId,
email,
},
requiredPermission: 'TAB_STAFF_READ',
});
return {
success: true,
data: {

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date'
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } },
@@ -42,6 +43,7 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -323,6 +325,30 @@ export class TasksService {
return result;
});
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_COMPLETED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
requiredPermission: 'TAB_TASKS_READ',
});
const clinicOrgId = task.labCase?.treatment?.organization?.id;
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.TASK_COMPLETED,
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: task.labCaseId,
});
}
}
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,

View File

@@ -6,7 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@@ -27,6 +27,7 @@ import {
} from '../../common/lab-case-due-date';
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
import {
generateTreatmentTitle,
normalizeTeeth,
@@ -130,6 +131,7 @@ export class TreatmentsService {
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -815,6 +817,17 @@ export class TreatmentsService {
}
});
if (isFirstSend && labCase.destinationOrganizationId) {
void this.userNotifications.notify({
organizationId: labCase.destinationOrganizationId,
type: UserNotificationType.CASE_SENT,
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId },
requiredPermission: 'TAB_CASES_READ',
});
}
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
where: { id: labCaseId },
include: {

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { Server } from 'socket.io';
export function userOrgRoom(userId: string, organizationId: string): string {
return `user:${userId}:org:${organizationId}`;
}
@Injectable()
export class RealtimeEmitter {
private server: Server | null = null;
setServer(server: Server) {
this.server = server;
}
emitToUserOrg(userId: string, organizationId: string, event: string, payload: unknown) {
if (!this.server) return;
this.server.to(userOrgRoom(userId, organizationId)).emit(event, payload);
}
}

View File

@@ -0,0 +1,81 @@
import {
OnGatewayConnection,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { RealtimeEmitter, userOrgRoom } from './realtime.emitter';
type AccessPayload = {
sub?: string;
organizationId?: string;
type?: string;
};
@WebSocketGateway({
namespace: '/realtime',
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
credentials: true,
},
})
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
private readonly logger = new Logger(RealtimeGateway.name);
@WebSocketServer()
server!: Server;
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly emitter: RealtimeEmitter,
) {}
afterInit(server: Server) {
this.emitter.setServer(server);
}
async handleConnection(client: Socket) {
try {
const token = this.readAccessToken(client);
if (!token) {
client.disconnect(true);
return;
}
const payload = await this.jwt.verifyAsync<AccessPayload>(token, {
secret: this.config.get<string>('jwt.secret'),
});
if (!payload?.sub || !payload.organizationId || payload.type !== 'access') {
client.disconnect(true);
return;
}
const room = userOrgRoom(payload.sub, payload.organizationId);
await client.join(room);
client.data.userId = payload.sub;
client.data.organizationId = payload.organizationId;
} catch (error) {
this.logger.debug(`Realtime auth failed: ${String(error)}`);
client.disconnect(true);
}
}
private readAccessToken(client: Socket): string | null {
const cookieHeader = client.handshake.headers.cookie;
if (!cookieHeader) return null;
const parts = cookieHeader.split(';');
for (const part of parts) {
const [rawKey, ...rest] = part.trim().split('=');
if (rawKey === 'accessToken') {
return decodeURIComponent(rest.join('='));
}
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeEmitter } from './realtime.emitter';
import { RealtimeGateway } from './realtime.gateway';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('jwt.secret'),
}),
}),
],
providers: [RealtimeEmitter, RealtimeGateway],
exports: [RealtimeEmitter],
})
export class RealtimeModule {}

View File

@@ -761,6 +761,7 @@
"activityClinicComment": "{actor}: “{preview}” · {date}",
"activityLabComment": "{actor}: “{preview}” · {date}",
"activityTaskCompleted": "{step} completed by {actor} · {date}",
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
"activityCaseImportant": "Marked important by {actor} · {date}",
"activityCaseAmended": "Case updated by {actor} · {date}",
"activityGeneric": "Update · {date}",
@@ -968,6 +969,36 @@
"monthNovember": "November",
"monthDecember": "December"
},
"notifications": {
"bellAria": "Notifications",
"unreadCount": "{count} unread notifications",
"dropdownTitle": "Notifications",
"viewAll": "View all",
"pageTitle": "Notifications",
"pageSubtitle": "Updates from cases, tasks, organizations, and staff.",
"loading": "Loading notifications…",
"empty": "No notifications yet.",
"loadMore": "Load more",
"markAllRead": "Mark all as read",
"errorLoad": "Could not load notifications.",
"errorMarkRead": "Could not update notification.",
"typeCaseSent": "New lab case received",
"typeClinicComment": "New clinic comment on a case",
"typeLabComment": "New lab comment on a case",
"typeLabCommentClinic": "New lab comment on your case",
"typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification",
"destCases": "Open Cases",
"destTasks": "Open Tasks",
"destTreatment": "Open Treatment",
"destOrganizations": "Open Organizations",
"destStaff": "Open Staff",
"destOpen": "Open"
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",

View File

@@ -762,6 +762,7 @@
"activityClinicComment": "{actor}: «{preview}» · {date}",
"activityLabComment": "{actor}: «{preview}» · {date}",
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
"activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}",
"activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}",
"activityGeneric": "به‌روزرسانی · {date}",
@@ -969,6 +970,36 @@
"monthNovember": "نوامبر",
"monthDecember": "دسامبر"
},
"notifications": {
"bellAria": "اعلان‌ها",
"unreadCount": "{count} اعلان خوانده‌نشده",
"dropdownTitle": "اعلان‌ها",
"viewAll": "مشاهده همه",
"pageTitle": "اعلان‌ها",
"pageSubtitle": "به‌روزرسانی‌های پرونده‌ها، وظایف، سازمان‌ها و کارکنان.",
"loading": "در حال بارگذاری اعلان‌ها…",
"empty": "هنوز اعلانی نیست.",
"loadMore": "بیشتر",
"markAllRead": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"errorLoad": "بارگذاری اعلان‌ها ممکن نشد.",
"errorMarkRead": "به‌روزرسانی اعلان ممکن نشد.",
"typeCaseSent": "پرونده جدید در لابراتوار دریافت شد",
"typeClinicComment": "نظر جدید کلینیک روی پرونده",
"typeLabComment": "نظر جدید لابراتوار روی پرونده",
"typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما",
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان",
"destCases": "باز کردن پرونده‌ها",
"destTasks": "باز کردن وظایف",
"destTreatment": "باز کردن درمان",
"destOrganizations": "باز کردن سازمان‌ها",
"destStaff": "باز کردن کارکنان",
"destOpen": "باز کردن"
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",

View File

@@ -761,6 +761,7 @@
"activityClinicComment": "{actor}: “{preview}” · {date}",
"activityLabComment": "{actor}: “{preview}” · {date}",
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
"activityGeneric": "Update · {date}",
@@ -968,6 +969,36 @@
"monthNovember": "November",
"monthDecember": "December"
},
"notifications": {
"bellAria": "Meldingen",
"unreadCount": "{count} ongelezen meldingen",
"dropdownTitle": "Meldingen",
"viewAll": "Alles bekijken",
"pageTitle": "Meldingen",
"pageSubtitle": "Updates van cases, taken, organisaties en personeel.",
"loading": "Meldingen laden…",
"empty": "Nog geen meldingen.",
"loadMore": "Meer laden",
"markAllRead": "Alles als gelezen markeren",
"errorLoad": "Meldingen konden niet worden geladen.",
"errorMarkRead": "Melding kon niet worden bijgewerkt.",
"typeCaseSent": "Nieuwe labcase ontvangen",
"typeClinicComment": "Nieuwe kliniekreactie op een case",
"typeLabComment": "Nieuwe labreactie op een case",
"typeLabCommentClinic": "Nieuwe labreactie op uw case",
"typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding",
"destCases": "Cases openen",
"destTasks": "Taken openen",
"destTreatment": "Behandeling openen",
"destOrganizations": "Organisaties openen",
"destStaff": "Personeel openen",
"destOpen": "Openen"
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",

View File

@@ -20,6 +20,7 @@
"react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -1627,6 +1628,12 @@
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3614,7 +3621,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3736,6 +3742,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
@@ -5962,7 +5990,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -7096,6 +7123,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7844,6 +7899,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

@@ -21,6 +21,7 @@
"react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import { NotificationBell } from '@/components/ui/notifications/NotificationBell';
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider';
import {
canAccessDashboardRoute,
firstAccessibleDashboardPath,
@@ -87,6 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<ToastProvider>
<RealtimeProvider>
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
{sidebarOpen ? (
<button
@@ -112,6 +115,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</main>
</div>
</div>
</RealtimeProvider>
</ToastProvider>
);
}
@@ -141,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
<TopBarControls />
<NotificationBell />
<DashboardAccountMenu />
</div>
</header>

View File

@@ -0,0 +1,7 @@
'use client';
import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage';
export default function NotificationsRoutePage() {
return <NotificationsPage />;
}

View File

@@ -10,6 +10,7 @@ export default function TreatmentPage() {
const { user, currentOrganization, isAuthReady } = useAuth();
const searchParams = useSearchParams();
const initialAppointmentId = searchParams.get('appointmentId');
const initialLabCaseId = searchParams.get('labCaseId');
if (!isAuthReady || !user) {
return (
@@ -22,6 +23,7 @@ export default function TreatmentPage() {
userId={user.id}
currentOrganization={currentOrganization}
initialAppointmentId={initialAppointmentId}
initialLabCaseId={initialLabCaseId}
/>
);
}

View File

@@ -16,7 +16,7 @@ import {
} from '@/components/lab/caseDetailUtils';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { notificationsApi } from '@/lib/api/notifications';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
@@ -106,16 +106,22 @@ export function CasesPage() {
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
);
const loadCases = async (params: {
q: string;
clinicOrganizationId: string;
prosthesisTypeCode: string;
sentFrom: string;
sentTo: string;
page: number;
}) => {
setLoadingList(true);
toast.setError('');
const loadCases = async (
params: {
q: string;
clinicOrganizationId: string;
prosthesisTypeCode: string;
sentFrom: string;
sentTo: string;
page: number;
},
options?: { silent?: boolean },
) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoadingList(true);
toast.setError('');
}
try {
const response = await casesApi.list({
q: params.q.trim() || undefined,
@@ -129,9 +135,11 @@ export function CasesPage() {
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
if (!silent) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
}
} finally {
setLoadingList(false);
if (!silent) setLoadingList(false);
}
};
@@ -213,6 +221,28 @@ export function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]);
useEffect(() => {
const onBadgesChanged = () => {
void loadCases(
{
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page,
},
{ silent: true },
);
if (selectedCaseId) {
void loadDetail(selectedCaseId, { silent: true });
}
};
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox socket
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page, selectedCaseId]);
useEffect(() => {
if (!selectedCaseId) {
setMobileDetailOpen(false);
@@ -267,6 +297,7 @@ export function CasesPage() {
try {
const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId);
setSelectedCase(response.data);
notifyTabBadgesChanged();
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask')));
} finally {

View File

@@ -20,7 +20,7 @@ import {
} from '@/components/lab/tasksViewDefaults';
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
@@ -173,17 +173,48 @@ export function TasksPage() {
setPage(1);
}, [searchParams]);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
useEffect(() => {
const taskId = searchParams.get('taskId')?.trim();
if (!taskId || !canView) return;
let cancelled = false;
void (async () => {
try {
const response = await tasksApi.locatePage(buildDefaultLocateParams(taskId, PAGE_SIZE));
if (cancelled || !response.data.found) return;
setStatusFilter('');
setImportantOnly(false);
setOverdueOnly(false);
setUnassignedOnly(false);
setProsthesisTypeCode('');
setSortBy('date');
setSortDir('desc');
setPage(response.data.page);
setHighlightTaskId(taskId);
} catch {
/* ignore deep-link locate failures */
}
})();
return () => {
cancelled = true;
};
}, [searchParams, canView]);
const loadTasks = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
setError('');
}
try {
const response = await tasksApi.list(listParams);
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
if (!silent) {
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
}
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
}, [listParams, showError, setError, tErrors]);
@@ -195,6 +226,15 @@ export function TasksPage() {
return () => clearTimeout(timeout);
}, [canView, loadTasks, search]);
useEffect(() => {
if (!canView) return;
const onBadgesChanged = () => {
void loadTasks({ silent: true });
};
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
}, [canView, loadTasks]);
useEffect(() => {
if (!canView) return;
void (async () => {

View File

@@ -0,0 +1,154 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bell } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { NavBadgePill } from '@/components/ui/shared/NavBadgePill';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import type { UserNotificationItem } from '@/types/notifications';
const DROPDOWN_LIMIT = 10;
export function NotificationBell() {
const t = useTranslations('notifications');
const router = useRouter();
const { lastNotification, unreadCount: liveUnread, setUnreadCount } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [unreadCount, setLocalUnread] = useState(0);
const [loading, setLoading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const refreshUnread = useCallback(async () => {
try {
const res = await notificationsApi.inboxUnreadCount();
const count = res.data.count;
setLocalUnread(count);
setUnreadCount(count);
} catch {
/* ignore */
}
}, [setUnreadCount]);
const loadRecent = useCallback(async () => {
setLoading(true);
try {
const res = await notificationsApi.listInbox({ limit: DROPDOWN_LIMIT });
setItems(res.data.items);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshUnread();
}, [refreshUnread]);
useEffect(() => {
if (typeof liveUnread === 'number') {
setLocalUnread(liveUnread);
}
}, [liveUnread]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev].slice(0, DROPDOWN_LIMIT);
});
}, [lastNotification]);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
if (open) {
void loadRecent();
}
}, [open, loadRecent]);
const handleSelect = async (item: UserNotificationItem) => {
setOpen(false);
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
await refreshUnread();
}
} catch {
/* still navigate */
}
router.push(item.href);
};
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={t('bellAria')}
aria-expanded={open}
aria-haspopup="dialog"
>
<Bell className="h-[18px] w-[18px] icon-flat" />
{unreadCount > 0 ? (
<span className="absolute -top-1 -end-1">
<NavBadgePill count={unreadCount} ariaLabel={t('unreadCount', { count: unreadCount })} />
</span>
) : null}
</button>
{open ? (
<div
role="dialog"
aria-label={t('dropdownTitle')}
className="fixed inset-x-3 top-[4.75rem] z-[200] max-h-[min(70dvh,28rem)] overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm sm:absolute sm:inset-x-auto sm:end-0 sm:top-auto sm:mt-2 sm:w-[min(22rem,calc(100vw-1.5rem))]"
>
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-3 py-2">
<p className="text-sm font-medium text-text-primary">{t('dropdownTitle')}</p>
<Link
href="/notifications"
className="text-xs text-primary hover:underline shrink-0"
onClick={() => setOpen(false)}
>
{t('viewAll')}
</Link>
</div>
<div className="max-h-[min(calc(70dvh-2.75rem),24rem)] overflow-y-auto overscroll-contain p-2 space-y-1.5">
{loading && items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard
key={item.id}
item={item}
compact
onSelect={handleSelect}
/>
))
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
import { formatAppDateTime } from '@/lib/i18n/format';
import type { UserNotificationItem, UserNotificationType } from '@/types/notifications';
const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_SENT: 'typeCaseSent',
CLINIC_COMMENT: 'typeClinicComment',
LAB_COMMENT: 'typeLabComment',
LAB_COMMENT_CLINIC: 'typeLabCommentClinic',
CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned',
CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite',
};
function destKeyFromHref(href: string): string {
if (href.startsWith('/cases')) return 'destCases';
if (href.startsWith('/tasks')) return 'destTasks';
if (href.startsWith('/treatment')) return 'destTreatment';
if (href.startsWith('/organizations')) return 'destOrganizations';
if (href.startsWith('/staff')) return 'destStaff';
return 'destOpen';
}
function asString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatProsthesisCode(code: string): string {
return code.replace(/_/g, ' ');
}
/** Build a single truncated context string from denormalized inbox payload. */
export function notificationContextLine(
item: UserNotificationItem,
): string | null {
const payload = item.payload ?? {};
const parts: string[] = [];
const patientName = asString(payload.patientName);
const clinicName = asString(payload.clinicName);
const labName = asString(payload.labName);
const taskName = asString(payload.taskName);
const fromOrganizationName = asString(payload.fromOrganizationName);
const inviteeName = asString(payload.inviteeName);
const email = asString(payload.email);
const prosthesisTypeCode = asString(payload.prosthesisTypeCode);
const prosthesisCodes = Array.isArray(payload.prosthesisTypeCodes)
? payload.prosthesisTypeCodes
.filter((code): code is string => typeof code === 'string' && code.trim().length > 0)
.map((code) => formatProsthesisCode(code.trim()))
: [];
const prosthesisLabel =
prosthesisTypeCode != null
? formatProsthesisCode(prosthesisTypeCode)
: prosthesisCodes.length > 0
? prosthesisCodes.slice(0, 2).join(', ')
: null;
switch (item.type) {
case 'CASE_SENT':
case 'CLINIC_COMMENT':
case 'LAB_COMMENT':
case 'CASE_IMPORTANT':
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'LAB_COMMENT_CLINIC':
if (patientName) parts.push(patientName);
if (labName) parts.push(labName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'TASK_COMPLETED':
case 'TASK_ASSIGNED':
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
else if (labName) parts.push(labName);
if (taskName) parts.push(taskName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'CONNECTION_REQUEST':
if (fromOrganizationName) parts.push(fromOrganizationName);
break;
case 'STAFF_INVITE':
if (inviteeName) parts.push(inviteeName);
if (email) parts.push(email);
break;
default:
// Fallback: show any denormalized fields even if type is unexpected.
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
if (labName) parts.push(labName);
if (taskName) parts.push(taskName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
}
if (parts.length === 0) return null;
return parts.join(' · ');
}
export function NotificationCard({
item,
onSelect,
compact = false,
}: {
item: UserNotificationItem;
onSelect: (item: UserNotificationItem) => void;
compact?: boolean;
}) {
const t = useTranslations('notifications');
const locale = useLocale();
const unread = !item.readAt;
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
const destKey = destKeyFromHref(item.href);
const context = notificationContextLine(item);
return (
<button
type="button"
onClick={() => onSelect(item)}
className={`w-full min-h-11 text-start rounded-[var(--radius-md)] border px-3 py-2.5 sm:py-3 transition-colors ${
unread
? 'border-primary/40 bg-primary/5 hover:border-primary/60'
: 'border-border/70 bg-background-secondary/40 hover:border-border'
}`}
>
<div className="flex items-start gap-2.5 sm:gap-3">
{unread ? (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg" aria-hidden />
) : (
<span className="mt-1.5 h-2 w-2 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p
className={`font-medium text-text-primary ${
compact ? 'text-sm' : 'text-sm sm:text-base'
}`}
>
{t(titleKey)}
</p>
<div
className={`mt-0.5 flex min-w-0 items-center gap-x-2 text-text-muted ${
compact ? 'text-[11px]' : 'text-xs sm:text-sm'
}`}
>
{context ? (
<>
<span className="min-w-0 flex-1 truncate" title={context}>
{context}
</span>
<span className="text-border shrink-0" aria-hidden>
·
</span>
</>
) : null}
<span className="shrink-0 whitespace-nowrap">
{formatAppDateTime(item.createdAt, locale)}
</span>
<span className="text-border shrink-0" aria-hidden>
·
</span>
<span className="shrink-0 whitespace-nowrap">{t(destKey)}</span>
</div>
</div>
<ChevronRight
className="mt-0.5 h-4 w-4 shrink-0 text-text-muted rtl:rotate-180"
aria-hidden
/>
</div>
</button>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { Button } from '@/components/ui/shared/Button';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { UserNotificationItem } from '@/types/notifications';
export function NotificationsPage() {
const t = useTranslations('notifications');
const tErrors = useTranslations('errors');
const router = useRouter();
const toast = useToast();
const { lastNotification, setUnreadCount } = useRealtime();
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadPage = useCallback(async (cursor?: string | null, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const res = await notificationsApi.listInbox({
limit: 20,
cursor: cursor ?? undefined,
});
setItems((prev) => (append ? [...prev, ...res.data.items] : res.data.items));
setNextCursor(res.data.nextCursor);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [t, tErrors, toast]);
useEffect(() => {
void loadPage();
}, [loadPage]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev];
});
}, [lastNotification]);
const handleSelect = async (item: UserNotificationItem) => {
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
const countRes = await notificationsApi.inboxUnreadCount();
setUnreadCount(countRes.data.count);
}
} catch {
/* still navigate */
}
router.push(item.href);
};
const handleMarkAll = async () => {
try {
await notificationsApi.markInboxReadAll();
setItems((prev) =>
prev.map((row) => ({ ...row, readAt: row.readAt ?? new Date().toISOString() })),
);
setUnreadCount(0);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorMarkRead')));
}
};
return (
<div className="space-y-4 w-full">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
<p className="text-sm text-text-muted mt-1">{t('pageSubtitle')}</p>
</div>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto shrink-0"
onClick={() => void handleMarkAll()}
>
{t('markAllRead')}
</Button>
</div>
<div className="space-y-2 w-full">
{loading ? (
<p className="text-sm text-text-muted">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-sm text-text-muted surface-card p-4">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard key={item.id} item={item} onSelect={(row) => void handleSelect(row)} />
))
)}
</div>
{nextCursor ? (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
isLoading={loadingMore}
onClick={() => void loadPage(nextCursor, true)}
>
{t('loadMore')}
</Button>
) : null}
</div>
);
}

View File

@@ -107,21 +107,36 @@ export function OrganizationsPage() {
const existingRows = items;
async function loadList() {
setLoading(true);
toast.setError('');
async function loadList(options?: { silent?: boolean }) {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
toast.setError('');
}
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
toast.showError(formatApiMessage(e));
if (!silent) {
toast.showError(formatApiMessage(e));
}
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
}
useEffect(() => {
void loadList();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
}, []);
useEffect(() => {
const onChanged = () => {
void loadList({ silent: true });
};
window.addEventListener('pending-connections-changed', onChanged);
return () => window.removeEventListener('pending-connections-changed', onChanged);
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox
}, []);
useEffect(() => {

View File

@@ -318,12 +318,14 @@ interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
initialAppointmentId?: string | null;
initialLabCaseId?: string | null;
}
export function TreatmentWorkspace({
userId,
currentOrganization,
initialAppointmentId = null,
initialLabCaseId = null,
}: TreatmentWorkspaceProps) {
const locale = useLocale();
const t = useTranslations('treatment');
@@ -404,6 +406,7 @@ export function TreatmentWorkspace({
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
@@ -417,6 +420,10 @@ export function TreatmentWorkspace({
}
}, [initialAppointmentId]);
useEffect(() => {
pendingLabCaseIdRef.current = initialLabCaseId;
}, [initialLabCaseId]);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
@@ -1351,6 +1358,17 @@ export function TreatmentWorkspace({
],
);
useEffect(() => {
const labCaseId = pendingLabCaseIdRef.current;
if (!labCaseId) return;
const match =
unreadLabCases.find((item) => item.labCaseId === labCaseId) ??
patientLabCases.find((item) => item.labCaseId === labCaseId);
if (!match) return;
pendingLabCaseIdRef.current = null;
void handleSelectPatientLabCase(match);
}, [unreadLabCases, patientLabCases, handleSelectPatientLabCase]);
useEffect(() => {
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
if (match) {

View File

@@ -1,6 +1,7 @@
// src/lib/api/client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiError } from '@/types/api';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
@@ -79,6 +80,7 @@ apiClient.interceptors.response.use(
}
}
notifyAccessTokenRefreshed();
return apiClient(originalRequest);
} catch (refreshError) {
if (typeof window !== 'undefined') {

View File

@@ -1,4 +1,5 @@
import { apiClient } from '@/lib/api/client';
import type { InboxPage, UserNotificationItem } from '@/types/notifications';
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
@@ -27,4 +28,29 @@ export const notificationsApi = {
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
return response.data;
},
listInbox: async (params?: {
limit?: number;
cursor?: string;
}): Promise<{ success: boolean; data: InboxPage }> => {
const response = await apiClient.get('/notifications/inbox', { params });
return response.data;
},
inboxUnreadCount: async (): Promise<{ success: boolean; data: { count: number } }> => {
const response = await apiClient.get('/notifications/inbox/unread-count');
return response.data;
},
markInboxRead: async (
id: string,
): Promise<{ success: boolean; data: UserNotificationItem | null }> => {
const response = await apiClient.post(`/notifications/inbox/${id}/read`);
return response.data;
},
markInboxReadAll: async (): Promise<{ success: boolean }> => {
const response = await apiClient.post('/notifications/inbox/read-all');
return response.data;
},
};

View File

@@ -0,0 +1,11 @@
/** Dispatched after a successful access-token refresh so realtime can reconnect with the new cookie. */
export const ACCESS_TOKEN_REFRESHED_EVENT = 'dyolink:access-token-refreshed';
export function notifyAccessTokenRefreshed() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(ACCESS_TOKEN_REFRESHED_EVENT));
}
export function accessTokenRefreshedEventName() {
return ACCESS_TOKEN_REFRESHED_EVENT;
}

View File

@@ -1,4 +1,5 @@
import { authApi } from '@/lib/api/auth';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
/** Refresh this long before the access JWT expires. */
@@ -91,6 +92,7 @@ async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
}
lastRefreshAt = Date.now();
notifyAccessTokenRefreshed();
return expiresAt;
} finally {
refreshInFlight = null;

View File

@@ -16,6 +16,7 @@ import {
rememberAccessTokenExpiresAt,
startProactiveSessionRefresh,
} from '@/lib/auth/proactiveRefresh';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
@@ -162,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgId) {
await authApi.selectOrganization(orgId);
}
notifyAccessTokenRefreshed();
const retry = await authApi.getProfile();
if (retry.success) {
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);

View File

@@ -35,6 +35,12 @@ export function formatLabCaseActivityLine(
actor,
date,
});
case 'TASK_ASSIGNED':
return t('activityTaskAssigned', {
step: activity.stepLabel ?? t('activityUnknownStep'),
actor,
date,
});
case 'CASE_IMPORTANT':
return t('activityCaseImportant', { actor, date });
case 'CASE_AMENDED':

View File

@@ -0,0 +1,131 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { io, type Socket } from 'socket.io-client';
import { useAuth } from '@/lib/hooks/useAuth';
import { accessTokenRefreshedEventName } from '@/lib/auth/accessTokenEvents';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import type { UserNotificationItem } from '@/types/notifications';
type RealtimeContextValue = {
connected: boolean;
lastNotification: UserNotificationItem | null;
unreadCount: number | null;
setUnreadCount: (count: number) => void;
};
const RealtimeContext = createContext<RealtimeContextValue>({
connected: false,
lastNotification: null,
unreadCount: null,
setUnreadCount: () => undefined,
});
function apiOrigin(): string {
const base = process.env.NEXT_PUBLIC_API_URL ?? '';
try {
return new URL(base).origin;
} catch {
return typeof window !== 'undefined' ? window.location.origin : '';
}
}
function invalidateSidebarBadges(notification?: UserNotificationItem | null) {
// Sidebar Cases/Tasks/Treatment badges + open tab soft-refetch (via window event).
notifyTabBadgesChanged();
// Orgs pending badge/list — only for connection-related inbox events.
if (
notification?.type === 'CONNECTION_REQUEST' ||
notification?.href?.startsWith('/organizations')
) {
notifyPendingConnectionsChanged();
}
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();
const [connected, setConnected] = useState(false);
const [lastNotification, setLastNotification] = useState<UserNotificationItem | null>(null);
const [unreadCount, setUnreadCount] = useState<number | null>(null);
/** Bumped after access-token refresh so the socket reconnects with the new cookie. */
const [socketEpoch, setSocketEpoch] = useState(0);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const onRefreshed = () => setSocketEpoch((n) => n + 1);
window.addEventListener(accessTokenRefreshedEventName(), onRefreshed);
return () => window.removeEventListener(accessTokenRefreshedEventName(), onRefreshed);
}, []);
useEffect(() => {
if (!isAuthReady || !user || !currentOrganization?.id) {
socketRef.current?.disconnect();
socketRef.current = null;
setConnected(false);
return;
}
const socket = io(`${apiOrigin()}/realtime`, {
withCredentials: true,
transports: ['websocket', 'polling'],
// Avoid hammering the gateway with an expired cookie; we reconnect after refresh.
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});
socketRef.current = socket;
socket.on('connect', () => setConnected(true));
socket.on('disconnect', () => setConnected(false));
socket.on('connect_error', () => setConnected(false));
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
if (payload?.notification) {
setLastNotification(payload.notification);
invalidateSidebarBadges(payload.notification);
} else {
invalidateSidebarBadges(null);
}
});
socket.on('notification.unreadCount', (payload: { count?: number }) => {
if (typeof payload?.count === 'number') {
setUnreadCount(payload.count);
}
});
return () => {
socket.disconnect();
socketRef.current = null;
setConnected(false);
};
}, [isAuthReady, user, currentOrganization?.id, socketEpoch]);
const setUnreadCountStable = useCallback((count: number) => {
setUnreadCount(count);
}, []);
const value = useMemo(
() => ({
connected,
lastNotification,
unreadCount,
setUnreadCount: setUnreadCountStable,
}),
[connected, lastNotification, unreadCount, setUnreadCountStable],
);
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
}
export function useRealtime() {
return useContext(RealtimeContext);
}

View File

@@ -4,7 +4,8 @@ export type LabCaseActivityType =
| 'LAB_COMMENT'
| 'CASE_IMPORTANT'
| 'CASE_AMENDED'
| 'TASK_COMPLETED';
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED';
export interface LabCaseActivityItem {
id: string;

View File

@@ -0,0 +1,25 @@
export type UserNotificationType =
| 'CASE_SENT'
| 'CLINIC_COMMENT'
| 'LAB_COMMENT'
| 'LAB_COMMENT_CLINIC'
| 'CASE_IMPORTANT'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED'
| 'CONNECTION_REQUEST'
| 'STAFF_INVITE';
export type UserNotificationItem = {
id: string;
type: UserNotificationType;
href: string;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
actorUserId: string | null;
};
export type InboxPage = {
items: UserNotificationItem[];
nextCursor: string | null;
};