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.
|
||||
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
|
||||
- **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`
|
||||
|
||||
@@ -54,5 +54,10 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
||||
|
||||
## Out of scope (later steps)
|
||||
|
||||
- Push / email / websockets
|
||||
- Push / email
|
||||
- Making Cases/Tasks/Treatment lists live via websockets
|
||||
- `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-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/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime |
|
||||
| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
|
||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||
|
||||
485
backend/package-lock.json
generated
485
backend/package-lock.json
generated
@@ -20,8 +20,10 @@
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.28",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.28",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"adminjs": "^7.8.17",
|
||||
"axios": "^1.13.5",
|
||||
@@ -41,10 +43,9 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^6.19.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
@@ -61,9 +62,6 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -4485,6 +4483,25 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io": {
|
||||
"version": "11.1.28",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/platform-socket.io/-/platform-socket.io-11.1.28.tgz",
|
||||
"integrity": "sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||
@@ -4580,6 +4597,29 @@
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/websockets": {
|
||||
"version": "11.1.28",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/websockets/-/websockets-11.1.28.tgz",
|
||||
"integrity": "sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
"object-hash": "3.0.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0",
|
||||
"rxjs": "^7.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nestjs/platform-socket.io": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -5320,6 +5360,12 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -6088,6 +6134,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -6293,16 +6348,6 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -6325,16 +6370,6 @@
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
@@ -6424,6 +6459,15 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -7553,6 +7597,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7562,6 +7607,7 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -7893,6 +7939,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.27",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
|
||||
@@ -8174,6 +8229,7 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -8451,6 +8507,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -8463,6 +8520,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -8785,15 +8843,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -8907,12 +8956,6 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -9031,6 +9074,7 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
@@ -9051,6 +9095,79 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.9",
|
||||
"resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-6.6.9.tgz",
|
||||
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||
@@ -10074,6 +10191,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -10691,6 +10809,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -12646,6 +12765,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -12895,6 +13023,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -13208,15 +13337,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/polished": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
|
||||
@@ -13635,133 +13755,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
@@ -13971,19 +13964,6 @@
|
||||
"react-dom": "^16.8.0 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz",
|
||||
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
|
||||
@@ -14209,6 +14189,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -14224,12 +14205,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -14514,12 +14489,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -14664,6 +14633,90 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
@@ -14826,6 +14879,7 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -14856,6 +14910,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -16346,12 +16401,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -16373,6 +16422,7 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -16422,6 +16472,27 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
|
||||
@@ -41,8 +41,10 @@
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.28",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.28",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"adminjs": "^7.8.17",
|
||||
"axios": "^1.13.5",
|
||||
@@ -64,6 +66,7 @@
|
||||
"prisma": "^6.19.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserNotificationType" AS ENUM (
|
||||
'CASE_SENT',
|
||||
'CLINIC_COMMENT',
|
||||
'LAB_COMMENT',
|
||||
'LAB_COMMENT_CLINIC',
|
||||
'CASE_IMPORTANT',
|
||||
'TASK_COMPLETED',
|
||||
'TASK_ASSIGNED',
|
||||
'CONNECTION_REQUEST',
|
||||
'STAFF_INVITE'
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"type" "UserNotificationType" NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"payload" JSONB,
|
||||
"href" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "user_notifications_userId_organizationId_createdAt_idx"
|
||||
ON "user_notifications"("userId", "organizationId", "createdAt");
|
||||
|
||||
CREATE INDEX "user_notifications_userId_organizationId_readAt_idx"
|
||||
ON "user_notifications"("userId", "organizationId", "readAt");
|
||||
|
||||
ALTER TABLE "user_notifications"
|
||||
ADD CONSTRAINT "user_notifications_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "user_notifications"
|
||||
ADD CONSTRAINT "user_notifications_actorUserId_fkey"
|
||||
FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -30,6 +30,8 @@ model User {
|
||||
labCaseComments LabCaseComment[]
|
||||
labCaseActivities LabCaseActivity[]
|
||||
phoneVerificationCodes PhoneVerificationCode[]
|
||||
userNotifications UserNotification[]
|
||||
actedUserNotifications UserNotification[] @relation("UserNotificationActor")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -435,6 +437,38 @@ enum LabCaseTabReadTarget {
|
||||
TREATMENT
|
||||
}
|
||||
|
||||
enum UserNotificationType {
|
||||
CASE_SENT
|
||||
CLINIC_COMMENT
|
||||
LAB_COMMENT
|
||||
LAB_COMMENT_CLINIC
|
||||
CASE_IMPORTANT
|
||||
TASK_COMPLETED
|
||||
TASK_ASSIGNED
|
||||
CONNECTION_REQUEST
|
||||
STAFF_INVITE
|
||||
}
|
||||
|
||||
/// Fan-out inbox row per recipient. Independent of LabCaseActivity tab badges.
|
||||
model UserNotification {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
organizationId String
|
||||
type UserNotificationType
|
||||
actorUserId String?
|
||||
payload Json?
|
||||
href String
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
actorUser User? @relation("UserNotificationActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, organizationId, createdAt])
|
||||
@@index([userId, organizationId, readAt])
|
||||
@@map("user_notifications")
|
||||
}
|
||||
|
||||
model LabCaseActivity {
|
||||
id String @id @default(uuid())
|
||||
labCaseId String
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
import { TodayModule } from './modules/today/today.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
NotificationsModule,
|
||||
RealtimeModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import { LabCaseAccessService } from './lab-case-access.service';
|
||||
|
||||
const labCaseListInclude = {
|
||||
@@ -96,6 +97,7 @@ export class CasesService {
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
private readonly labCaseAccess: LabCaseAccessService,
|
||||
) {}
|
||||
|
||||
@@ -385,6 +387,14 @@ export class CasesService {
|
||||
type: LabCaseActivityType.CASE_IMPORTANT,
|
||||
actorUserId,
|
||||
});
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.CASE_IMPORTANT,
|
||||
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
||||
@@ -476,6 +486,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({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
const commentInclude = {
|
||||
authorUser: { select: { id: true, name: true } },
|
||||
@@ -24,6 +25,7 @@ export class LabCaseCommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
// ---------- Lab side (TAB_TASKS_EDIT) ----------
|
||||
@@ -67,6 +69,31 @@ export class LabCaseCommentsService {
|
||||
visibleToClinic: created.visibleToClinic,
|
||||
},
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.LAB_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_TASKS_READ',
|
||||
});
|
||||
|
||||
if (created.visibleToClinic) {
|
||||
const clinicOrgId = await this.clinicOrgIdForCase(caseId);
|
||||
if (clinicOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: clinicOrgId,
|
||||
type: UserNotificationType.LAB_COMMENT_CLINIC,
|
||||
href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_TREATMENT_READ',
|
||||
labCaseIdForProviderScope: caseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
|
||||
}
|
||||
|
||||
@@ -130,6 +157,17 @@ export class LabCaseCommentsService {
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
const labOrgId = await this.labOrgIdForCase(caseId);
|
||||
if (labOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrgId,
|
||||
type: UserNotificationType.CLINIC_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
@@ -172,6 +210,17 @@ export class LabCaseCommentsService {
|
||||
actorUserId,
|
||||
payload: { commentId: created.id },
|
||||
});
|
||||
const labOrgId = await this.labOrgIdForCase(caseId);
|
||||
if (labOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrgId,
|
||||
type: UserNotificationType.CLINIC_COMMENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: caseId, commentId: created.id },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||
}
|
||||
|
||||
@@ -336,4 +385,21 @@ export class LabCaseCommentsService {
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to treatment cases');
|
||||
}
|
||||
|
||||
private async labOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
const send = await this.prisma.labCaseSend.findFirst({
|
||||
where: { labCaseId: caseId },
|
||||
orderBy: { sentAt: 'asc' },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
return send?.organizationId ?? null;
|
||||
}
|
||||
|
||||
private async clinicOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
const labCase = await this.prisma.labCase.findUnique({
|
||||
where: { id: caseId },
|
||||
select: { treatment: { select: { organizationId: true } } },
|
||||
});
|
||||
return labCase?.treatment.organizationId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { LabCaseTabReadTarget } from '@prisma/client';
|
||||
|
||||
export class MarkTabReadDto {
|
||||
@@ -10,3 +11,16 @@ export class MarkCaseReadDto {
|
||||
@IsUUID()
|
||||
labCaseId!: string;
|
||||
}
|
||||
|
||||
export class ListInboxQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto';
|
||||
import {
|
||||
ListInboxQueryDto,
|
||||
MarkCaseReadDto,
|
||||
MarkTabReadDto,
|
||||
} from './dto/notifications.dto';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
@ApiTags('notifications')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
|
||||
constructor(
|
||||
private readonly labCaseActivityService: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
@Get('tab-counts')
|
||||
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
|
||||
@@ -69,4 +88,53 @@ export class NotificationsController {
|
||||
dto.labCaseId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('inbox')
|
||||
@ApiOperation({ summary: 'Paginated notification inbox for the current user and org' })
|
||||
listInbox(
|
||||
@Query() query: ListInboxQueryDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: { items: [], nextCursor: null } };
|
||||
}
|
||||
return this.userNotifications.listInbox(req.user.id, organizationId, {
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('inbox/unread-count')
|
||||
@ApiOperation({ summary: 'Unread inbox count for the header bell' })
|
||||
inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: { count: 0 } };
|
||||
}
|
||||
return this.userNotifications.unreadCount(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('inbox/read-all')
|
||||
@ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' })
|
||||
markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true };
|
||||
}
|
||||
return this.userNotifications.markAllRead(req.user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post('inbox/:id/read')
|
||||
@ApiOperation({ summary: 'Mark a single inbox notification as read' })
|
||||
markInboxRead(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = req.user.organizationId;
|
||||
if (!organizationId) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
return this.userNotifications.markRead(req.user.id, organizationId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LabCaseActivityService } from './lab-case-activity.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
import { RealtimeModule } from '../../realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [LabCaseActivityService],
|
||||
exports: [LabCaseActivityService],
|
||||
providers: [LabCaseActivityService, UserNotificationService],
|
||||
exports: [LabCaseActivityService, UserNotificationService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
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 { CasesModule } from '../cases/cases.module';
|
||||
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OrganizationController } from './organization.controller';
|
||||
import { OrganizationService } from './organization.service';
|
||||
|
||||
@Module({
|
||||
imports: [CasesModule, LabCaseCommentsModule],
|
||||
imports: [CasesModule, LabCaseCommentsModule, NotificationsModule],
|
||||
controllers: [OrganizationController],
|
||||
providers: [OrganizationService, PrismaService],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LinkStatus } from '@prisma/client';
|
||||
import { LinkStatus, UserNotificationType } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
@@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto';
|
||||
import { CasesService } from '../cases/cases.service';
|
||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
|
||||
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
|
||||
import { InviteOrganizationDto } from './dto/invite-organization.dto';
|
||||
@@ -36,6 +37,7 @@ export class OrganizationService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly casesService: CasesService,
|
||||
private readonly commentsService: LabCaseCommentsService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -269,6 +271,15 @@ export class OrganizationService {
|
||||
},
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId: dto.targetOrganizationId,
|
||||
type: UserNotificationType.CONNECTION_REQUEST,
|
||||
href: '/organizations',
|
||||
actorUserId: userId,
|
||||
payload: { organizationLinkId: created.id, fromOrganizationId: organizationId },
|
||||
requiredPermission: 'TAB_ORGANIZATIONS_READ',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: created.id, status: created.status },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [StaffController],
|
||||
providers: [StaffService, StaffWorkingHoursService, PrismaService],
|
||||
exports: [StaffWorkingHoursService],
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
||||
@@ -17,10 +17,14 @@ import {
|
||||
} from '../../common/organization-type';
|
||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class StaffService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
@@ -217,6 +221,19 @@ export class StaffService {
|
||||
};
|
||||
});
|
||||
|
||||
void this.userNotifications.notify({
|
||||
organizationId,
|
||||
type: UserNotificationType.STAFF_INVITE,
|
||||
href: '/staff',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
membershipId: result.membershipId,
|
||||
staffInvitationId: result.invitationId,
|
||||
email,
|
||||
},
|
||||
requiredPermission: 'TAB_STAFF_READ',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date'
|
||||
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
|
||||
const taskListInclude = {
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
@@ -42,6 +43,7 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -323,6 +325,30 @@ export class TasksService {
|
||||
return result;
|
||||
});
|
||||
|
||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labOrganizationId,
|
||||
type: UserNotificationType.TASK_COMPLETED,
|
||||
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: task.labCaseId, taskId },
|
||||
requiredPermission: 'TAB_TASKS_READ',
|
||||
});
|
||||
|
||||
const clinicOrgId = task.labCase?.treatment?.organization?.id;
|
||||
if (clinicOrgId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: clinicOrgId,
|
||||
type: UserNotificationType.TASK_COMPLETED,
|
||||
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId: task.labCaseId, taskId },
|
||||
requiredPermission: 'TAB_TREATMENT_READ',
|
||||
labCaseIdForProviderScope: task.labCaseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../../common/lab-case-due-date';
|
||||
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { UserNotificationService } from '../notifications/user-notification.service';
|
||||
import {
|
||||
generateTreatmentTitle,
|
||||
normalizeTeeth,
|
||||
@@ -130,6 +131,7 @@ export class TreatmentsService {
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly userNotifications: UserNotificationService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -815,6 +817,17 @@ export class TreatmentsService {
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirstSend && labCase.destinationOrganizationId) {
|
||||
void this.userNotifications.notify({
|
||||
organizationId: labCase.destinationOrganizationId,
|
||||
type: UserNotificationType.CASE_SENT,
|
||||
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
|
||||
actorUserId,
|
||||
payload: { labCaseId },
|
||||
requiredPermission: 'TAB_CASES_READ',
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: {
|
||||
|
||||
20
backend/src/realtime/realtime.emitter.ts
Normal file
20
backend/src/realtime/realtime.emitter.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
export function userOrgRoom(userId: string, organizationId: string): string {
|
||||
return `user:${userId}:org:${organizationId}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RealtimeEmitter {
|
||||
private server: Server | null = null;
|
||||
|
||||
setServer(server: Server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
emitToUserOrg(userId: string, organizationId: string, event: string, payload: unknown) {
|
||||
if (!this.server) return;
|
||||
this.server.to(userOrgRoom(userId, organizationId)).emit(event, payload);
|
||||
}
|
||||
}
|
||||
81
backend/src/realtime/realtime.gateway.ts
Normal file
81
backend/src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayInit,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { RealtimeEmitter, userOrgRoom } from './realtime.emitter';
|
||||
|
||||
type AccessPayload = {
|
||||
sub?: string;
|
||||
organizationId?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/realtime',
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly emitter: RealtimeEmitter,
|
||||
) {}
|
||||
|
||||
afterInit(server: Server) {
|
||||
this.emitter.setServer(server);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
const token = this.readAccessToken(client);
|
||||
if (!token) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwt.verifyAsync<AccessPayload>(token, {
|
||||
secret: this.config.get<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
if (!payload?.sub || !payload.organizationId || payload.type !== 'access') {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = userOrgRoom(payload.sub, payload.organizationId);
|
||||
await client.join(room);
|
||||
client.data.userId = payload.sub;
|
||||
client.data.organizationId = payload.organizationId;
|
||||
} catch (error) {
|
||||
this.logger.debug(`Realtime auth failed: ${String(error)}`);
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
private readAccessToken(client: Socket): string | null {
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (!cookieHeader) return null;
|
||||
const parts = cookieHeader.split(';');
|
||||
for (const part of parts) {
|
||||
const [rawKey, ...rest] = part.trim().split('=');
|
||||
if (rawKey === 'accessToken') {
|
||||
return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
20
backend/src/realtime/realtime.module.ts
Normal file
20
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RealtimeEmitter } from './realtime.emitter';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('jwt.secret'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [RealtimeEmitter, RealtimeGateway],
|
||||
exports: [RealtimeEmitter],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
@@ -968,6 +968,30 @@
|
||||
"monthNovember": "November",
|
||||
"monthDecember": "December"
|
||||
},
|
||||
"notifications": {
|
||||
"bellAria": "Notifications",
|
||||
"unreadCount": "{count} unread notifications",
|
||||
"dropdownTitle": "Notifications",
|
||||
"viewAll": "View all",
|
||||
"pageTitle": "Notifications",
|
||||
"pageSubtitle": "Updates from cases, tasks, organizations, and staff.",
|
||||
"loading": "Loading notifications…",
|
||||
"empty": "No notifications yet.",
|
||||
"loadMore": "Load more",
|
||||
"markAllRead": "Mark all as read",
|
||||
"errorLoad": "Could not load notifications.",
|
||||
"errorMarkRead": "Could not update notification.",
|
||||
"typeCaseSent": "New lab case received",
|
||||
"typeClinicComment": "New clinic comment on a case",
|
||||
"typeLabComment": "New lab comment on a case",
|
||||
"typeLabCommentClinic": "New lab comment on your case",
|
||||
"typeCaseImportant": "Case marked as important",
|
||||
"typeTaskCompleted": "Lab task completed",
|
||||
"typeTaskAssigned": "A task was assigned to you",
|
||||
"typeConnectionRequest": "New organization connection request",
|
||||
"typeStaffInvite": "Staff invitation created",
|
||||
"typeUnknown": "Notification"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Something went wrong. Please try again.",
|
||||
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",
|
||||
|
||||
@@ -969,6 +969,30 @@
|
||||
"monthNovember": "نوامبر",
|
||||
"monthDecember": "دسامبر"
|
||||
},
|
||||
"notifications": {
|
||||
"bellAria": "اعلانها",
|
||||
"unreadCount": "{count} اعلان خواندهنشده",
|
||||
"dropdownTitle": "اعلانها",
|
||||
"viewAll": "مشاهده همه",
|
||||
"pageTitle": "اعلانها",
|
||||
"pageSubtitle": "بهروزرسانیهای پروندهها، وظایف، سازمانها و کارکنان.",
|
||||
"loading": "در حال بارگذاری اعلانها…",
|
||||
"empty": "هنوز اعلانی نیست.",
|
||||
"loadMore": "بیشتر",
|
||||
"markAllRead": "علامتگذاری همه بهعنوان خواندهشده",
|
||||
"errorLoad": "بارگذاری اعلانها ممکن نشد.",
|
||||
"errorMarkRead": "بهروزرسانی اعلان ممکن نشد.",
|
||||
"typeCaseSent": "پرونده جدید در لابراتوار دریافت شد",
|
||||
"typeClinicComment": "نظر جدید کلینیک روی پرونده",
|
||||
"typeLabComment": "نظر جدید لابراتوار روی پرونده",
|
||||
"typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما",
|
||||
"typeCaseImportant": "پرونده بهعنوان مهم علامت خورد",
|
||||
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
|
||||
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
|
||||
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
|
||||
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
|
||||
"typeUnknown": "اعلان"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
||||
|
||||
@@ -968,6 +968,30 @@
|
||||
"monthNovember": "November",
|
||||
"monthDecember": "December"
|
||||
},
|
||||
"notifications": {
|
||||
"bellAria": "Meldingen",
|
||||
"unreadCount": "{count} ongelezen meldingen",
|
||||
"dropdownTitle": "Meldingen",
|
||||
"viewAll": "Alles bekijken",
|
||||
"pageTitle": "Meldingen",
|
||||
"pageSubtitle": "Updates van cases, taken, organisaties en personeel.",
|
||||
"loading": "Meldingen laden…",
|
||||
"empty": "Nog geen meldingen.",
|
||||
"loadMore": "Meer laden",
|
||||
"markAllRead": "Alles als gelezen markeren",
|
||||
"errorLoad": "Meldingen konden niet worden geladen.",
|
||||
"errorMarkRead": "Melding kon niet worden bijgewerkt.",
|
||||
"typeCaseSent": "Nieuwe labcase ontvangen",
|
||||
"typeClinicComment": "Nieuwe kliniekreactie op een case",
|
||||
"typeLabComment": "Nieuwe labreactie op een case",
|
||||
"typeLabCommentClinic": "Nieuwe labreactie op uw case",
|
||||
"typeCaseImportant": "Case gemarkeerd als belangrijk",
|
||||
"typeTaskCompleted": "Labtaak voltooid",
|
||||
"typeTaskAssigned": "Er is een taak aan u toegewezen",
|
||||
"typeConnectionRequest": "Nieuw organisatieverzoek",
|
||||
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
|
||||
"typeUnknown": "Melding"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",
|
||||
|
||||
88
frontend/package-lock.json
generated
88
frontend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"recharts": "^3.9.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1627,6 +1628,12 @@
|
||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -3614,7 +3621,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3736,6 +3742,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
@@ -5962,7 +5990,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -7096,6 +7123,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -7844,6 +7899,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"recharts": "^3.9.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
|
||||
import Sidebar from '@/components/ui/shared/Sidebar';
|
||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||
import { NotificationBell } from '@/components/ui/notifications/NotificationBell';
|
||||
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
|
||||
import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider';
|
||||
import {
|
||||
canAccessDashboardRoute,
|
||||
firstAccessibleDashboardPath,
|
||||
@@ -87,6 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<RealtimeProvider>
|
||||
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
||||
{sidebarOpen ? (
|
||||
<button
|
||||
@@ -112,6 +115,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</RealtimeProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
<TopBarControls />
|
||||
<NotificationBell />
|
||||
<DashboardAccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage';
|
||||
|
||||
export default function NotificationsRoutePage() {
|
||||
return <NotificationsPage />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export default function TreatmentPage() {
|
||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const initialAppointmentId = searchParams.get('appointmentId');
|
||||
const initialLabCaseId = searchParams.get('labCaseId');
|
||||
|
||||
if (!isAuthReady || !user) {
|
||||
return (
|
||||
@@ -22,6 +23,7 @@ export default function TreatmentPage() {
|
||||
userId={user.id}
|
||||
currentOrganization={currentOrganization}
|
||||
initialAppointmentId={initialAppointmentId}
|
||||
initialLabCaseId={initialLabCaseId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,32 @@ export function TasksPage() {
|
||||
setPage(1);
|
||||
}, [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 () => {
|
||||
setLoading(true);
|
||||
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;
|
||||
currentOrganization: Organization | null;
|
||||
initialAppointmentId?: string | null;
|
||||
initialLabCaseId?: string | null;
|
||||
}
|
||||
|
||||
export function TreatmentWorkspace({
|
||||
userId,
|
||||
currentOrganization,
|
||||
initialAppointmentId = null,
|
||||
initialLabCaseId = null,
|
||||
}: TreatmentWorkspaceProps) {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('treatment');
|
||||
@@ -404,6 +406,7 @@ export function TreatmentWorkspace({
|
||||
labCaseDraftsRef.current = labCaseDrafts;
|
||||
const skipNextGetDraftRef = useRef(false);
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||
const historyRequestRef = useRef(0);
|
||||
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
||||
@@ -417,6 +420,10 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
}, [initialAppointmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingLabCaseIdRef.current = initialLabCaseId;
|
||||
}, [initialLabCaseId]);
|
||||
|
||||
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
||||
@@ -1351,6 +1358,17 @@ export function TreatmentWorkspace({
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const labCaseId = pendingLabCaseIdRef.current;
|
||||
if (!labCaseId) return;
|
||||
const match =
|
||||
unreadLabCases.find((item) => item.labCaseId === labCaseId) ??
|
||||
patientLabCases.find((item) => item.labCaseId === labCaseId);
|
||||
if (!match) return;
|
||||
pendingLabCaseIdRef.current = null;
|
||||
void handleSelectPatientLabCase(match);
|
||||
}, [unreadLabCases, patientLabCases, handleSelectPatientLabCase]);
|
||||
|
||||
useEffect(() => {
|
||||
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
|
||||
if (match) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { InboxPage, UserNotificationItem } from '@/types/notifications';
|
||||
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
||||
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
|
||||
|
||||
@@ -27,4 +28,29 @@ export const notificationsApi = {
|
||||
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listInbox: async (params?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<{ success: boolean; data: InboxPage }> => {
|
||||
const response = await apiClient.get('/notifications/inbox', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
inboxUnreadCount: async (): Promise<{ success: boolean; data: { count: number } }> => {
|
||||
const response = await apiClient.get('/notifications/inbox/unread-count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markInboxRead: async (
|
||||
id: string,
|
||||
): Promise<{ success: boolean; data: UserNotificationItem | null }> => {
|
||||
const response = await apiClient.post(`/notifications/inbox/${id}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markInboxReadAll: async (): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post('/notifications/inbox/read-all');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
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