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
|
## 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`.
|
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)
|
- 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`.
|
- **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
|
## Permissions
|
||||||
|
|
||||||
`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes.
|
`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.
|
- **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`.
|
- **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
|
## 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
|
name: dyolink-tab-badges
|
||||||
description: Lab case activity feed + sidebar tab badge counts. Use when changing notifications API, LabCaseActivity, read state, or Sidebar badges for Cases/Tasks/Treatment.
|
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/)
|
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)
|
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
|
## 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.
|
- **`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
|
- **`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 |
|
| Org | Tab | Activity types |
|
||||||
|-----|-----|----------------|
|
|-----|-----|----------------|
|
||||||
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
|
| 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** |
|
| 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`.
|
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` |
|
| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` |
|
||||||
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
||||||
| Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` |
|
| 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).
|
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)
|
## 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)
|
- `useMarkTabReadOnVisit()` — Tasks page only (Cases/Treatment badges clear when opening unread cases)
|
||||||
- `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx)
|
- `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx)
|
||||||
- **Organizations** pending connections still use `usePendingConnectionsCount` (separate pending-state API)
|
- **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)
|
## 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)
|
- `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"`).
|
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
|
||||||
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
- 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
|
## 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.
|
- **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.
|
- **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).
|
- **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`.
|
- **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`.
|
**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):**
|
**Lab case share link (quick ref):**
|
||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- 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-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-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-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/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
|
||||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
| `.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/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.28",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
@@ -41,10 +43,9 @@
|
|||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"react-qr-code": "^2.2.0",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"styled-components": "^6.3.11",
|
"styled-components": "^6.3.11",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -61,9 +62,6 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.16.0",
|
"@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/styled-components": "^5.1.36",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -4485,6 +4483,25 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@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": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -4580,6 +4597,29 @@
|
|||||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
"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": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
@@ -5320,6 +5360,12 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -6088,6 +6134,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -6293,16 +6348,6 @@
|
|||||||
"pg-types": "^2.2.0"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -6325,16 +6370,6 @@
|
|||||||
"csstype": "^3.2.2"
|
"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": {
|
"node_modules/@types/react-transition-group": {
|
||||||
"version": "4.4.12",
|
"version": "4.4.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
"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==",
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -7553,6 +7597,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7562,6 +7607,7 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -7893,6 +7939,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.27",
|
"version": "2.10.27",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
|
||||||
@@ -8174,6 +8229,7 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -8451,6 +8507,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -8463,6 +8520,7 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -8907,12 +8956,6 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -9031,6 +9074,7 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/empathic": {
|
"node_modules/empathic": {
|
||||||
@@ -9051,6 +9095,79 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.0",
|
"version": "5.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
@@ -10074,6 +10191,7 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -10691,6 +10809,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -12646,6 +12765,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -12895,6 +13023,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -13208,15 +13337,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/polished": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
|
||||||
@@ -13635,133 +13755,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -13971,19 +13964,6 @@
|
|||||||
"react-dom": "^16.8.0 || ^17 || ^18"
|
"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": {
|
"node_modules/react-redux": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||||
@@ -14209,6 +14189,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -14224,12 +14205,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -14514,12 +14489,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -14664,6 +14633,90 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
@@ -14826,6 +14879,7 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -14856,6 +14910,7 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -16346,12 +16401,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -16373,6 +16422,7 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -16422,6 +16472,27 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"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": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
@@ -41,8 +41,10 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.28",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"styled-components": "^6.3.11",
|
"styled-components": "^6.3.11",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"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[]
|
labCaseComments LabCaseComment[]
|
||||||
labCaseActivities LabCaseActivity[]
|
labCaseActivities LabCaseActivity[]
|
||||||
phoneVerificationCodes PhoneVerificationCode[]
|
phoneVerificationCodes PhoneVerificationCode[]
|
||||||
|
userNotifications UserNotification[]
|
||||||
|
actedUserNotifications UserNotification[] @relation("UserNotificationActor")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -427,6 +429,7 @@ enum LabCaseActivityType {
|
|||||||
CASE_IMPORTANT
|
CASE_IMPORTANT
|
||||||
CASE_AMENDED
|
CASE_AMENDED
|
||||||
TASK_COMPLETED
|
TASK_COMPLETED
|
||||||
|
TASK_ASSIGNED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LabCaseTabReadTarget {
|
enum LabCaseTabReadTarget {
|
||||||
@@ -435,6 +438,38 @@ enum LabCaseTabReadTarget {
|
|||||||
TREATMENT
|
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 {
|
model LabCaseActivity {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
labCaseId String
|
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 { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||||
import { TodayModule } from './modules/today/today.module';
|
import { TodayModule } from './modules/today/today.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
|||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
TodayModule,
|
TodayModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
RealtimeModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
|||||||
LabCaseActivityType.CASE_IMPORTANT,
|
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[] = [
|
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||||
LabCaseActivityType.TASK_COMPLETED,
|
LabCaseActivityType.TASK_COMPLETED,
|
||||||
LabCaseActivityType.LAB_COMMENT,
|
LabCaseActivityType.LAB_COMMENT,
|
||||||
|
LabCaseActivityType.TASK_ASSIGNED,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clinic Treatment tab — visible lab comments and task progress. */
|
/** Clinic Treatment tab — visible lab comments and task progress. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
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 { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { normalizeMobile } from '../../common/phone';
|
import { normalizeMobile } from '../../common/phone';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
|
|||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||||
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||||
import { LabCaseAccessService } from './lab-case-access.service';
|
import { LabCaseAccessService } from './lab-case-access.service';
|
||||||
|
|
||||||
const labCaseListInclude = {
|
const labCaseListInclude = {
|
||||||
@@ -96,6 +97,7 @@ export class CasesService {
|
|||||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||||
private readonly catalogLabels: CatalogLabelService,
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
private readonly labCaseActivity: LabCaseActivityService,
|
private readonly labCaseActivity: LabCaseActivityService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
private readonly labCaseAccess: LabCaseAccessService,
|
private readonly labCaseAccess: LabCaseAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -385,6 +387,14 @@ export class CasesService {
|
|||||||
type: LabCaseActivityType.CASE_IMPORTANT,
|
type: LabCaseActivityType.CASE_IMPORTANT,
|
||||||
actorUserId,
|
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({
|
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({
|
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||||
where: { id: labCaseId },
|
where: { id: labCaseId },
|
||||||
include: labCaseListInclude,
|
include: labCaseListInclude,
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
|
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
|
||||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||||
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||||
|
|
||||||
const commentInclude = {
|
const commentInclude = {
|
||||||
authorUser: { select: { id: true, name: true } },
|
authorUser: { select: { id: true, name: true } },
|
||||||
@@ -24,6 +25,7 @@ export class LabCaseCommentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly labCaseActivity: LabCaseActivityService,
|
private readonly labCaseActivity: LabCaseActivityService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---------- Lab side (TAB_TASKS_EDIT) ----------
|
// ---------- Lab side (TAB_TASKS_EDIT) ----------
|
||||||
@@ -67,6 +69,31 @@ export class LabCaseCommentsService {
|
|||||||
visibleToClinic: created.visibleToClinic,
|
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) };
|
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +157,17 @@ export class LabCaseCommentsService {
|
|||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { commentId: created.id },
|
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) };
|
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +210,17 @@ export class LabCaseCommentsService {
|
|||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { commentId: created.id },
|
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) };
|
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');
|
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';
|
import { LabCaseTabReadTarget } from '@prisma/client';
|
||||||
|
|
||||||
export class MarkTabReadDto {
|
export class MarkTabReadDto {
|
||||||
@@ -10,3 +11,16 @@ export class MarkCaseReadDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
labCaseId!: string;
|
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;
|
const commentId = payload?.commentId;
|
||||||
if (typeof commentId === 'string') commentIds.push(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;
|
const taskId = payload?.taskId;
|
||||||
if (typeof taskId === 'string') taskIds.push(taskId);
|
if (typeof taskId === 'string') taskIds.push(taskId);
|
||||||
}
|
}
|
||||||
@@ -387,19 +390,45 @@ export class LabCaseActivityService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
|
||||||
|
const includesTaskAssigned = types.includes(LabCaseActivityType.TASK_ASSIGNED);
|
||||||
|
const otherTypes = types.filter((type) => type !== LabCaseActivityType.TASK_ASSIGNED);
|
||||||
|
|
||||||
return this.prisma.labCaseActivity.count({
|
// TASK_ASSIGNED: count for the assignee only (including self-assign).
|
||||||
where: {
|
// Other task-tab types: org-wide, excluding events the current user authored.
|
||||||
type: { in: types },
|
const typeFilter: Prisma.LabCaseActivityWhereInput = includesTaskAssigned
|
||||||
createdAt: { gt: since },
|
? {
|
||||||
labCase: labCaseScope,
|
OR: [
|
||||||
|
...(otherTypes.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
AND: [
|
AND: [
|
||||||
|
{ type: { in: otherTypes } },
|
||||||
{
|
{
|
||||||
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
|
||||||
},
|
},
|
||||||
clinicLabCommentFilter,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
createdAt: { gt: since },
|
||||||
|
labCase: labCaseScope,
|
||||||
|
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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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 { LabCaseActivityService } from './lab-case-activity.service';
|
||||||
|
import { UserNotificationService } from './user-notification.service';
|
||||||
|
|
||||||
@ApiTags('notifications')
|
@ApiTags('notifications')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
|
constructor(
|
||||||
|
private readonly labCaseActivityService: LabCaseActivityService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('tab-counts')
|
@Get('tab-counts')
|
||||||
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
|
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
|
||||||
@@ -69,4 +88,53 @@ export class NotificationsController {
|
|||||||
dto.labCaseId,
|
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 { Module } from '@nestjs/common';
|
||||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { UserNotificationService } from './user-notification.service';
|
||||||
|
import { RealtimeModule } from '../../realtime/realtime.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [RealtimeModule],
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [LabCaseActivityService],
|
providers: [LabCaseActivityService, UserNotificationService],
|
||||||
exports: [LabCaseActivityService],
|
exports: [LabCaseActivityService, UserNotificationService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
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 { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { CasesModule } from '../cases/cases.module';
|
import { CasesModule } from '../cases/cases.module';
|
||||||
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { OrganizationController } from './organization.controller';
|
import { OrganizationController } from './organization.controller';
|
||||||
import { OrganizationService } from './organization.service';
|
import { OrganizationService } from './organization.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CasesModule, LabCaseCommentsModule],
|
imports: [CasesModule, LabCaseCommentsModule, NotificationsModule],
|
||||||
controllers: [OrganizationController],
|
controllers: [OrganizationController],
|
||||||
providers: [OrganizationService, PrismaService],
|
providers: [OrganizationService, PrismaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LinkStatus } from '@prisma/client';
|
import { LinkStatus, UserNotificationType } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomBytes } from 'crypto';
|
import { createHash, randomBytes } from 'crypto';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
@@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto';
|
|||||||
import { CasesService } from '../cases/cases.service';
|
import { CasesService } from '../cases/cases.service';
|
||||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
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 { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
|
||||||
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
|
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
|
||||||
import { InviteOrganizationDto } from './dto/invite-organization.dto';
|
import { InviteOrganizationDto } from './dto/invite-organization.dto';
|
||||||
@@ -36,6 +37,7 @@ export class OrganizationService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly casesService: CasesService,
|
private readonly casesService: CasesService,
|
||||||
private readonly commentsService: LabCaseCommentsService,
|
private readonly commentsService: LabCaseCommentsService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { id: created.id, status: created.status },
|
data: { id: created.id, status: created.status },
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { StaffController } from './staff.controller';
|
import { StaffController } from './staff.controller';
|
||||||
import { StaffService } from './staff.service';
|
import { StaffService } from './staff.service';
|
||||||
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [NotificationsModule],
|
||||||
controllers: [StaffController],
|
controllers: [StaffController],
|
||||||
providers: [StaffService, StaffWorkingHoursService, PrismaService],
|
providers: [StaffService, StaffWorkingHoursService, PrismaService],
|
||||||
exports: [StaffWorkingHoursService],
|
exports: [StaffWorkingHoursService],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomBytes } from 'crypto';
|
import { createHash, randomBytes } from 'crypto';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, UserNotificationType } from '@prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||||
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
||||||
@@ -17,10 +17,14 @@ import {
|
|||||||
} from '../../common/organization-type';
|
} from '../../common/organization-type';
|
||||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||||
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StaffService {
|
export class StaffService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
if (!user?.organizationId) {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { normalizeMobile } from '../../common/phone';
|
import { normalizeMobile } from '../../common/phone';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date'
|
|||||||
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||||
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||||
|
|
||||||
const taskListInclude = {
|
const taskListInclude = {
|
||||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||||
@@ -42,6 +43,7 @@ export class TasksService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly catalogLabels: CatalogLabelService,
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
private readonly labCaseActivity: LabCaseActivityService,
|
private readonly labCaseActivity: LabCaseActivityService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
@@ -323,6 +325,30 @@ export class TasksService {
|
|||||||
return result;
|
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 locale = normalizeCatalogLocale(localeInput);
|
||||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AppException, ErrorCode } from '../../common/errors';
|
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 { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '../../common/lab-case-due-date';
|
} from '../../common/lab-case-due-date';
|
||||||
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||||
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||||
import {
|
import {
|
||||||
generateTreatmentTitle,
|
generateTreatmentTitle,
|
||||||
normalizeTeeth,
|
normalizeTeeth,
|
||||||
@@ -130,6 +131,7 @@ export class TreatmentsService {
|
|||||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||||
private readonly labCaseActivity: LabCaseActivityService,
|
private readonly labCaseActivity: LabCaseActivityService,
|
||||||
|
private readonly userNotifications: UserNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
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({
|
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||||
where: { id: labCaseId },
|
where: { id: labCaseId },
|
||||||
include: {
|
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}",
|
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityLabComment": "{actor}: “{preview}” · {date}",
|
"activityLabComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
||||||
|
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
|
||||||
"activityCaseImportant": "Marked important by {actor} · {date}",
|
"activityCaseImportant": "Marked important by {actor} · {date}",
|
||||||
"activityCaseAmended": "Case updated by {actor} · {date}",
|
"activityCaseAmended": "Case updated by {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
@@ -968,6 +969,36 @@
|
|||||||
"monthNovember": "November",
|
"monthNovember": "November",
|
||||||
"monthDecember": "December"
|
"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": {
|
"errors": {
|
||||||
"GENERIC": "Something went wrong. Please try again.",
|
"GENERIC": "Something went wrong. Please try again.",
|
||||||
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",
|
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",
|
||||||
|
|||||||
@@ -762,6 +762,7 @@
|
|||||||
"activityClinicComment": "{actor}: «{preview}» · {date}",
|
"activityClinicComment": "{actor}: «{preview}» · {date}",
|
||||||
"activityLabComment": "{actor}: «{preview}» · {date}",
|
"activityLabComment": "{actor}: «{preview}» · {date}",
|
||||||
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
||||||
|
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
|
||||||
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
||||||
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
||||||
"activityGeneric": "بهروزرسانی · {date}",
|
"activityGeneric": "بهروزرسانی · {date}",
|
||||||
@@ -969,6 +970,36 @@
|
|||||||
"monthNovember": "نوامبر",
|
"monthNovember": "نوامبر",
|
||||||
"monthDecember": "دسامبر"
|
"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": {
|
"errors": {
|
||||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||||
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
||||||
|
|||||||
@@ -761,6 +761,7 @@
|
|||||||
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
"activityClinicComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityLabComment": "{actor}: “{preview}” · {date}",
|
"activityLabComment": "{actor}: “{preview}” · {date}",
|
||||||
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
||||||
|
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
|
||||||
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
||||||
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
@@ -968,6 +969,36 @@
|
|||||||
"monthNovember": "November",
|
"monthNovember": "November",
|
||||||
"monthDecember": "December"
|
"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": {
|
"errors": {
|
||||||
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||||
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",
|
"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-hook-form": "^7.71.2",
|
||||||
"react-qr-code": "^2.0.15",
|
"react-qr-code": "^2.0.15",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1627,6 +1628,12 @@
|
|||||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -3614,7 +3621,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -3736,6 +3742,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.0",
|
"version": "5.20.0",
|
||||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
@@ -5962,7 +5990,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -7096,6 +7123,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -7844,6 +7899,35 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-qr-code": "^2.0.15",
|
"react-qr-code": "^2.0.15",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
|
|||||||
import Sidebar from '@/components/ui/shared/Sidebar';
|
import Sidebar from '@/components/ui/shared/Sidebar';
|
||||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||||
|
import { NotificationBell } from '@/components/ui/notifications/NotificationBell';
|
||||||
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
|
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
|
||||||
|
import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider';
|
||||||
import {
|
import {
|
||||||
canAccessDashboardRoute,
|
canAccessDashboardRoute,
|
||||||
firstAccessibleDashboardPath,
|
firstAccessibleDashboardPath,
|
||||||
@@ -87,6 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<RealtimeProvider>
|
||||||
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
||||||
{sidebarOpen ? (
|
{sidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
@@ -112,6 +115,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RealtimeProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||||
<TopBarControls />
|
<TopBarControls />
|
||||||
|
<NotificationBell />
|
||||||
<DashboardAccountMenu />
|
<DashboardAccountMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 { user, currentOrganization, isAuthReady } = useAuth();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const initialAppointmentId = searchParams.get('appointmentId');
|
const initialAppointmentId = searchParams.get('appointmentId');
|
||||||
|
const initialLabCaseId = searchParams.get('labCaseId');
|
||||||
|
|
||||||
if (!isAuthReady || !user) {
|
if (!isAuthReady || !user) {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +23,7 @@ export default function TreatmentPage() {
|
|||||||
userId={user.id}
|
userId={user.id}
|
||||||
currentOrganization={currentOrganization}
|
currentOrganization={currentOrganization}
|
||||||
initialAppointmentId={initialAppointmentId}
|
initialAppointmentId={initialAppointmentId}
|
||||||
|
initialLabCaseId={initialLabCaseId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '@/components/lab/caseDetailUtils';
|
} from '@/components/lab/caseDetailUtils';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import { notificationsApi } from '@/lib/api/notifications';
|
import { notificationsApi } from '@/lib/api/notifications';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
@@ -106,16 +106,22 @@ export function CasesPage() {
|
|||||||
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
|
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadCases = async (params: {
|
const loadCases = async (
|
||||||
|
params: {
|
||||||
q: string;
|
q: string;
|
||||||
clinicOrganizationId: string;
|
clinicOrganizationId: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
sentFrom: string;
|
sentFrom: string;
|
||||||
sentTo: string;
|
sentTo: string;
|
||||||
page: number;
|
page: number;
|
||||||
}) => {
|
},
|
||||||
|
options?: { silent?: boolean },
|
||||||
|
) => {
|
||||||
|
const silent = options?.silent ?? false;
|
||||||
|
if (!silent) {
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await casesApi.list({
|
const response = await casesApi.list({
|
||||||
q: params.q.trim() || undefined,
|
q: params.q.trim() || undefined,
|
||||||
@@ -129,9 +135,11 @@ export function CasesPage() {
|
|||||||
setCases(response.data.items);
|
setCases(response.data.items);
|
||||||
setPagination(response.data.pagination);
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (!silent) {
|
||||||
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
|
||||||
|
}
|
||||||
} finally {
|
} 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
|
||||||
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedCaseId) {
|
if (!selectedCaseId) {
|
||||||
setMobileDetailOpen(false);
|
setMobileDetailOpen(false);
|
||||||
@@ -267,6 +297,7 @@ export function CasesPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId);
|
const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId);
|
||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
|
notifyTabBadgesChanged();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask')));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '@/components/lab/tasksViewDefaults';
|
} from '@/components/lab/tasksViewDefaults';
|
||||||
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
|
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
|
||||||
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||||
@@ -173,17 +173,48 @@ export function TasksPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
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);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await tasksApi.list(listParams);
|
const response = await tasksApi.list(listParams);
|
||||||
setTasks(response.data.items);
|
setTasks(response.data.items);
|
||||||
setPagination(response.data.pagination);
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (!silent) {
|
||||||
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [listParams, showError, setError, tErrors]);
|
}, [listParams, showError, setError, tErrors]);
|
||||||
|
|
||||||
@@ -195,6 +226,15 @@ export function TasksPage() {
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [canView, loadTasks, search]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
void (async () => {
|
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;
|
const existingRows = items;
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList(options?: { silent?: boolean }) {
|
||||||
|
const silent = options?.silent ?? false;
|
||||||
|
if (!silent) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await organizationApi.list();
|
const res = await organizationApi.list();
|
||||||
setItems(res.data.items);
|
setItems(res.data.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!silent) {
|
||||||
toast.showError(formatApiMessage(e));
|
toast.showError(formatApiMessage(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadList();
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -318,12 +318,14 @@ interface TreatmentWorkspaceProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
currentOrganization: Organization | null;
|
currentOrganization: Organization | null;
|
||||||
initialAppointmentId?: string | null;
|
initialAppointmentId?: string | null;
|
||||||
|
initialLabCaseId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentWorkspace({
|
export function TreatmentWorkspace({
|
||||||
userId,
|
userId,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
initialAppointmentId = null,
|
initialAppointmentId = null,
|
||||||
|
initialLabCaseId = null,
|
||||||
}: TreatmentWorkspaceProps) {
|
}: TreatmentWorkspaceProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
@@ -404,6 +406,7 @@ export function TreatmentWorkspace({
|
|||||||
labCaseDraftsRef.current = labCaseDrafts;
|
labCaseDraftsRef.current = labCaseDrafts;
|
||||||
const skipNextGetDraftRef = useRef(false);
|
const skipNextGetDraftRef = useRef(false);
|
||||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||||
|
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const historyRequestRef = useRef(0);
|
const historyRequestRef = useRef(0);
|
||||||
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
||||||
@@ -417,6 +420,10 @@ export function TreatmentWorkspace({
|
|||||||
}
|
}
|
||||||
}, [initialAppointmentId]);
|
}, [initialAppointmentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingLabCaseIdRef.current = initialLabCaseId;
|
||||||
|
}, [initialLabCaseId]);
|
||||||
|
|
||||||
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
|
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/lib/api/client.ts
|
// src/lib/api/client.ts
|
||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import type { ApiError } from '@/types/api';
|
import type { ApiError } from '@/types/api';
|
||||||
|
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||||
|
|
||||||
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
@@ -79,6 +80,7 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyAccessTokenRefreshed();
|
||||||
return apiClient(originalRequest);
|
return apiClient(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { InboxPage, UserNotificationItem } from '@/types/notifications';
|
||||||
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
||||||
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
|
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
|
||||||
|
|
||||||
@@ -27,4 +28,29 @@ export const notificationsApi = {
|
|||||||
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
|
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
|
||||||
return response.data;
|
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 { authApi } from '@/lib/api/auth';
|
||||||
|
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||||
|
|
||||||
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
|
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
|
||||||
/** Refresh this long before the access JWT expires. */
|
/** Refresh this long before the access JWT expires. */
|
||||||
@@ -91,6 +92,7 @@ async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastRefreshAt = Date.now();
|
lastRefreshAt = Date.now();
|
||||||
|
notifyAccessTokenRefreshed();
|
||||||
return expiresAt;
|
return expiresAt;
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = null;
|
refreshInFlight = null;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
rememberAccessTokenExpiresAt,
|
rememberAccessTokenExpiresAt,
|
||||||
startProactiveSessionRefresh,
|
startProactiveSessionRefresh,
|
||||||
} from '@/lib/auth/proactiveRefresh';
|
} from '@/lib/auth/proactiveRefresh';
|
||||||
|
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||||
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
||||||
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
|
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (orgId) {
|
if (orgId) {
|
||||||
await authApi.selectOrganization(orgId);
|
await authApi.selectOrganization(orgId);
|
||||||
}
|
}
|
||||||
|
notifyAccessTokenRefreshed();
|
||||||
const retry = await authApi.getProfile();
|
const retry = await authApi.getProfile();
|
||||||
if (retry.success) {
|
if (retry.success) {
|
||||||
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);
|
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export function formatLabCaseActivityLine(
|
|||||||
actor,
|
actor,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
|
case 'TASK_ASSIGNED':
|
||||||
|
return t('activityTaskAssigned', {
|
||||||
|
step: activity.stepLabel ?? t('activityUnknownStep'),
|
||||||
|
actor,
|
||||||
|
date,
|
||||||
|
});
|
||||||
case 'CASE_IMPORTANT':
|
case 'CASE_IMPORTANT':
|
||||||
return t('activityCaseImportant', { actor, date });
|
return t('activityCaseImportant', { actor, date });
|
||||||
case 'CASE_AMENDED':
|
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'
|
| 'LAB_COMMENT'
|
||||||
| 'CASE_IMPORTANT'
|
| 'CASE_IMPORTANT'
|
||||||
| 'CASE_AMENDED'
|
| 'CASE_AMENDED'
|
||||||
| 'TASK_COMPLETED';
|
| 'TASK_COMPLETED'
|
||||||
|
| 'TASK_ASSIGNED';
|
||||||
|
|
||||||
export interface LabCaseActivityItem {
|
export interface LabCaseActivityItem {
|
||||||
id: string;
|
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