feature: version one notification feature implemented.
This commit is contained in:
@@ -10,5 +10,6 @@ alwaysApply: false
|
|||||||
- **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.
|
- **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`.
|
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
|
||||||
- **Orgs connections badge** stays on separate `pending-count` endpoint.
|
- **Orgs connections badge** stays on separate `pending-count` endpoint.
|
||||||
|
- **Header inbox** (bell) is separate — `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||||
|
|
||||||
Full map: `.cursor/skills/lab-notifications/SKILL.md`
|
Full map: `.cursor/skills/lab-notifications/SKILL.md`
|
||||||
|
|||||||
@@ -54,5 +54,10 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
|||||||
|
|
||||||
## Out of scope (later steps)
|
## Out of scope (later steps)
|
||||||
|
|
||||||
- Push / email / websockets
|
- Push / email
|
||||||
|
- Making Cases/Tasks/Treatment lists live via websockets
|
||||||
- `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.
|
||||||
|
|||||||
35
.cursor/skills/notifications-inbox/SKILL.md
Normal file
35
.cursor/skills/notifications-inbox/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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`) | REST + `tab-badges-changed` |
|
||||||
|
| Read | Per-card `readAt` only | Per-case / tab cursors |
|
||||||
|
|
||||||
|
Do **not** clear tab badges when marking an inbox card read.
|
||||||
|
|
||||||
|
## 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, CONNECTION_REQUEST, STAFF_INVITE — see plan / service call sites.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- `RealtimeProvider` in dashboard layout
|
||||||
|
- `NotificationBell` + `NotificationsPage` + `NotificationCard`
|
||||||
|
- Deep links: `/cases?caseId=`, `/tasks?taskId=`, `/treatment?labCaseId=`, `/organizations`, `/staff`
|
||||||
@@ -97,6 +97,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
|||||||
| `.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/lab-notifications/` | Tab badges: 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;
|
||||||
@@ -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
|
||||||
@@ -435,6 +437,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],
|
||||||
|
|||||||
@@ -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,17 @@ export class CasesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (assigneeUserId && assigneeUserId !== actorUserId) {
|
||||||
|
void this.userNotifications.notify({
|
||||||
|
organizationId: labOrganizationId,
|
||||||
|
type: UserNotificationType.TASK_ASSIGNED,
|
||||||
|
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
|
||||||
|
actorUserId,
|
||||||
|
payload: { labCaseId, taskId },
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
207
backend/src/modules/notifications/user-notification.service.ts
Normal file
207
backend/src/modules/notifications/user-notification.service.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async notify(input: FanoutInput): Promise<void> {
|
||||||
|
const recipientIds = input.recipientUserIds?.length
|
||||||
|
? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))]
|
||||||
|
: await this.resolveRecipients(input);
|
||||||
|
|
||||||
|
if (recipientIds.length === 0) return;
|
||||||
|
|
||||||
|
const rows = await this.prisma.userNotification.createManyAndReturn({
|
||||||
|
data: recipientIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
type: input.type,
|
||||||
|
actorUserId: input.actorUserId ?? null,
|
||||||
|
payload: (input.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 {}
|
||||||
@@ -968,6 +968,30 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -969,6 +969,30 @@
|
|||||||
"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": "اعلان"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||||
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
||||||
|
|||||||
@@ -968,6 +968,30 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,32 @@ export function TasksPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
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 () => {
|
const loadTasks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|||||||
149
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal file
149
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'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="absolute end-0 z-[200] mt-2 w-[min(22rem,calc(100vw-1.5rem))] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t('viewAll')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[min(24rem,60vh)] overflow-y-auto 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} onSelect={handleSelect} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationCard({
|
||||||
|
item,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
item: UserNotificationItem;
|
||||||
|
onSelect: (item: UserNotificationItem) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('notifications');
|
||||||
|
const locale = useLocale();
|
||||||
|
const unread = !item.readAt;
|
||||||
|
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={`w-full text-start rounded-[var(--radius-md)] border px-3 py-2.5 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">
|
||||||
|
{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="text-sm font-medium text-text-primary">{t(titleKey)}</p>
|
||||||
|
<p className="text-[11px] text-text-muted mt-0.5">
|
||||||
|
{formatAppDateTime(item.createdAt, locale)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal file
121
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'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 max-w-2xl">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<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" onClick={() => void handleMarkAll()}>
|
||||||
|
{t('markAllRead')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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"
|
||||||
|
isLoading={loadingMore}
|
||||||
|
onClick={() => void loadPage(nextCursor, true)}
|
||||||
|
>
|
||||||
|
{t('loadMore')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
100
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal file
100
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'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 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 : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => setConnected(true));
|
||||||
|
socket.on('disconnect', () => setConnected(false));
|
||||||
|
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
|
||||||
|
if (payload?.notification) {
|
||||||
|
setLastNotification(payload.notification);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
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