Compare commits
3 Commits
0740493384
...
408b2c3329
| Author | SHA1 | Date | |
|---|---|---|---|
| 408b2c3329 | |||
| 9941dca849 | |||
| 9f6ec193d2 |
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
16
.cursor/rules/tab-badges.mdc
Normal file
16
.cursor/rules/tab-badges.mdc
Normal 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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
55
.cursor/skills/notifications-inbox/SKILL.md
Normal file
55
.cursor/skills/notifications-inbox/SKILL.md
Normal 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`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
485
backend/package-lock.json
generated
485
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED';
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
350
backend/src/modules/notifications/user-notification.service.ts
Normal file
350
backend/src/modules/notifications/user-notification.service.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
20
backend/src/realtime/realtime.emitter.ts
Normal file
20
backend/src/realtime/realtime.emitter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
81
backend/src/realtime/realtime.gateway.ts
Normal file
81
backend/src/realtime/realtime.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
backend/src/realtime/realtime.module.ts
Normal file
20
backend/src/realtime/realtime.module.ts
Normal 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 {}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
88
frontend/package-lock.json
generated
88
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage';
|
||||
|
||||
export default function NotificationsRoutePage() {
|
||||
return <NotificationsPage />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
154
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal file
154
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
frontend/src/components/ui/notifications/NotificationCard.tsx
Normal file
182
frontend/src/components/ui/notifications/NotificationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal file
127
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
11
frontend/src/lib/auth/accessTokenEvents.ts
Normal file
11
frontend/src/lib/auth/accessTokenEvents.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
131
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal file
131
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal 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);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ export type LabCaseActivityType =
|
||||
| 'LAB_COMMENT'
|
||||
| 'CASE_IMPORTANT'
|
||||
| 'CASE_AMENDED'
|
||||
| 'TASK_COMPLETED';
|
||||
| 'TASK_COMPLETED'
|
||||
| 'TASK_ASSIGNED';
|
||||
|
||||
export interface LabCaseActivityItem {
|
||||
id: string;
|
||||
|
||||
25
frontend/src/types/notifications.ts
Normal file
25
frontend/src/types/notifications.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user