feature: version one notification feature implemented.

This commit is contained in:
2026-07-18 01:09:54 +03:30
parent 0740493384
commit 9f6ec193d2
40 changed files with 1679 additions and 224 deletions

View File

@@ -10,5 +10,6 @@ alwaysApply: false
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed. - **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`. - **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Orgs connections badge** stays on separate `pending-count` endpoint. - **Orgs connections badge** stays on separate `pending-count` endpoint.
- **Header inbox** (bell) is separate — `.cursor/skills/notifications-inbox/SKILL.md`.
Full map: `.cursor/skills/lab-notifications/SKILL.md` Full map: `.cursor/skills/lab-notifications/SKILL.md`

View File

@@ -54,5 +54,10 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
## Out of scope (later steps) ## Out of scope (later steps)
- Push / email / websockets - Push / email
- Making Cases/Tasks/Treatment lists live via websockets
- `CASE_AMENDED` emit (Step 7) - `CASE_AMENDED` emit (Step 7)
## Related: header inbox
Permission-free bell + `UserNotification` fan-out + Socket.IO — see `.cursor/skills/notifications-inbox/SKILL.md`. Independent of tab badge cursors.

View 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`

View File

@@ -97,6 +97,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment |
| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules |
| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors | | `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime |
| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
| `.cursor/skills/api-errors/` | New backend errors + frontend translations | | `.cursor/skills/api-errors/` | New backend errors + frontend translations |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -968,6 +968,30 @@
"monthNovember": "November", "monthNovember": "November",
"monthDecember": "December" "monthDecember": "December"
}, },
"notifications": {
"bellAria": "Notifications",
"unreadCount": "{count} unread notifications",
"dropdownTitle": "Notifications",
"viewAll": "View all",
"pageTitle": "Notifications",
"pageSubtitle": "Updates from cases, tasks, organizations, and staff.",
"loading": "Loading notifications…",
"empty": "No notifications yet.",
"loadMore": "Load more",
"markAllRead": "Mark all as read",
"errorLoad": "Could not load notifications.",
"errorMarkRead": "Could not update notification.",
"typeCaseSent": "New lab case received",
"typeClinicComment": "New clinic comment on a case",
"typeLabComment": "New lab comment on a case",
"typeLabCommentClinic": "New lab comment on your case",
"typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification"
},
"errors": { "errors": {
"GENERIC": "Something went wrong. Please try again.", "GENERIC": "Something went wrong. Please try again.",
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.", "NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",

View File

@@ -969,6 +969,30 @@
"monthNovember": "نوامبر", "monthNovember": "نوامبر",
"monthDecember": "دسامبر" "monthDecember": "دسامبر"
}, },
"notifications": {
"bellAria": "اعلان‌ها",
"unreadCount": "{count} اعلان خوانده‌نشده",
"dropdownTitle": "اعلان‌ها",
"viewAll": "مشاهده همه",
"pageTitle": "اعلان‌ها",
"pageSubtitle": "به‌روزرسانی‌های پرونده‌ها، وظایف، سازمان‌ها و کارکنان.",
"loading": "در حال بارگذاری اعلان‌ها…",
"empty": "هنوز اعلانی نیست.",
"loadMore": "بیشتر",
"markAllRead": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"errorLoad": "بارگذاری اعلان‌ها ممکن نشد.",
"errorMarkRead": "به‌روزرسانی اعلان ممکن نشد.",
"typeCaseSent": "پرونده جدید در لابراتوار دریافت شد",
"typeClinicComment": "نظر جدید کلینیک روی پرونده",
"typeLabComment": "نظر جدید لابراتوار روی پرونده",
"typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما",
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان"
},
"errors": { "errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.", "NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",

View File

@@ -968,6 +968,30 @@
"monthNovember": "November", "monthNovember": "November",
"monthDecember": "December" "monthDecember": "December"
}, },
"notifications": {
"bellAria": "Meldingen",
"unreadCount": "{count} ongelezen meldingen",
"dropdownTitle": "Meldingen",
"viewAll": "Alles bekijken",
"pageTitle": "Meldingen",
"pageSubtitle": "Updates van cases, taken, organisaties en personeel.",
"loading": "Meldingen laden…",
"empty": "Nog geen meldingen.",
"loadMore": "Meer laden",
"markAllRead": "Alles als gelezen markeren",
"errorLoad": "Meldingen konden niet worden geladen.",
"errorMarkRead": "Melding kon niet worden bijgewerkt.",
"typeCaseSent": "Nieuwe labcase ontvangen",
"typeClinicComment": "Nieuwe kliniekreactie op een case",
"typeLabComment": "Nieuwe labreactie op een case",
"typeLabCommentClinic": "Nieuwe labreactie op uw case",
"typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding"
},
"errors": { "errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.", "NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -173,6 +173,32 @@ export function TasksPage() {
setPage(1); setPage(1);
}, [searchParams]); }, [searchParams]);
useEffect(() => {
const taskId = searchParams.get('taskId')?.trim();
if (!taskId || !canView) return;
let cancelled = false;
void (async () => {
try {
const response = await tasksApi.locatePage(buildDefaultLocateParams(taskId, PAGE_SIZE));
if (cancelled || !response.data.found) return;
setStatusFilter('');
setImportantOnly(false);
setOverdueOnly(false);
setUnassignedOnly(false);
setProsthesisTypeCode('');
setSortBy('date');
setSortDir('desc');
setPage(response.data.page);
setHighlightTaskId(taskId);
} catch {
/* ignore deep-link locate failures */
}
})();
return () => {
cancelled = true;
};
}, [searchParams, canView]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

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

View File

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

View 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);
}

View File

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