diff --git a/.cursor/rules/lab-notifications.mdc b/.cursor/rules/lab-notifications.mdc index 6d0489a..2a56a8d 100644 --- a/.cursor/rules/lab-notifications.mdc +++ b/.cursor/rules/lab-notifications.mdc @@ -10,5 +10,6 @@ alwaysApply: false - **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed. - **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`. - **Orgs connections badge** stays on separate `pending-count` endpoint. +- **Header inbox** (bell) is separate — `.cursor/skills/notifications-inbox/SKILL.md`. Full map: `.cursor/skills/lab-notifications/SKILL.md` diff --git a/.cursor/skills/lab-notifications/SKILL.md b/.cursor/skills/lab-notifications/SKILL.md index 761efb8..a97ebd0 100644 --- a/.cursor/skills/lab-notifications/SKILL.md +++ b/.cursor/skills/lab-notifications/SKILL.md @@ -54,5 +54,10 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event). ## Out of scope (later steps) -- Push / email / websockets +- Push / email +- Making Cases/Tasks/Treatment lists live via websockets - `CASE_AMENDED` emit (Step 7) + +## Related: header inbox + +Permission-free bell + `UserNotification` fan-out + Socket.IO — see `.cursor/skills/notifications-inbox/SKILL.md`. Independent of tab badge cursors. diff --git a/.cursor/skills/notifications-inbox/SKILL.md b/.cursor/skills/notifications-inbox/SKILL.md new file mode 100644 index 0000000..2b1e6c6 --- /dev/null +++ b/.cursor/skills/notifications-inbox/SKILL.md @@ -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` diff --git a/AGENTS.md b/AGENTS.md index bd53c4f..c384079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | | `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors | +| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime | | `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | diff --git a/backend/package-lock.json b/backend/package-lock.json index 8757df9..e4c3c85 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,8 +20,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.28", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.28", "@prisma/client": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", @@ -41,10 +43,9 @@ "passport-local": "^1.0.0", "pg": "^8.18.0", "prisma": "^6.19.2", - "qrcode": "^1.5.4", - "react-qr-code": "^2.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "styled-components": "^6.3.11", "swagger-ui-express": "^5.0.1" }, @@ -61,9 +62,6 @@ "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", - "@types/qrcode": "^1.5.5", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "@types/styled-components": "^5.1.36", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", @@ -4485,6 +4483,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.28", + "resolved": "https://registry.npmmirror.com/@nestjs/platform-socket.io/-/platform-socket.io-11.1.28.tgz", + "integrity": "sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -4580,6 +4597,29 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.28", + "resolved": "https://registry.npmmirror.com/@nestjs/websockets/-/websockets-11.1.28.tgz", + "integrity": "sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -5320,6 +5360,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6088,6 +6134,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6293,16 +6348,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/qrcode": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz", - "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -6325,16 +6370,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -6424,6 +6459,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -7553,6 +7597,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7562,6 +7607,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7893,6 +7939,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", @@ -8174,6 +8229,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8451,6 +8507,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8463,6 +8520,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -8785,15 +8843,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -8907,12 +8956,6 @@ "node": ">=0.3.1" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -9031,6 +9074,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -9051,6 +9095,79 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -10074,6 +10191,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10691,6 +10809,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12646,6 +12765,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12895,6 +13023,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13208,15 +13337,6 @@ "node": ">=4" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -13635,133 +13755,6 @@ ], "license": "MIT" }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode-generator": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz", - "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", - "license": "MIT" - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -13971,19 +13964,6 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, - "node_modules/react-qr-code": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz", - "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.8.1", - "qrcode-generator": "^2.0.4" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -14209,6 +14189,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14224,12 +14205,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -14514,12 +14489,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14664,6 +14633,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14826,6 +14879,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14856,6 +14910,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -16346,12 +16401,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16373,6 +16422,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -16422,6 +16472,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", diff --git a/backend/package.json b/backend/package.json index 9f1e5a4..0346056 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,8 +41,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.28", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.28", "@prisma/client": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", @@ -64,6 +66,7 @@ "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "styled-components": "^6.3.11", "swagger-ui-express": "^5.0.1" }, diff --git a/backend/prisma/migrations/20260718120000_user_notifications/migration.sql b/backend/prisma/migrations/20260718120000_user_notifications/migration.sql new file mode 100644 index 0000000..faca011 --- /dev/null +++ b/backend/prisma/migrations/20260718120000_user_notifications/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e943bcb..847efd6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -30,6 +30,8 @@ model User { labCaseComments LabCaseComment[] labCaseActivities LabCaseActivity[] phoneVerificationCodes PhoneVerificationCode[] + userNotifications UserNotification[] + actedUserNotifications UserNotification[] @relation("UserNotificationActor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -435,6 +437,38 @@ enum LabCaseTabReadTarget { TREATMENT } +enum UserNotificationType { + CASE_SENT + CLINIC_COMMENT + LAB_COMMENT + LAB_COMMENT_CLINIC + CASE_IMPORTANT + TASK_COMPLETED + TASK_ASSIGNED + CONNECTION_REQUEST + STAFF_INVITE +} + +/// Fan-out inbox row per recipient. Independent of LabCaseActivity tab badges. +model UserNotification { + id String @id @default(uuid()) + userId String + organizationId String + type UserNotificationType + actorUserId String? + payload Json? + href String + readAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + actorUser User? @relation("UserNotificationActor", fields: [actorUserId], references: [id], onDelete: SetNull) + + @@index([userId, organizationId, createdAt]) + @@index([userId, organizationId, readAt]) + @@map("user_notifications") +} + model LabCaseActivity { id String @id @default(uuid()) labCaseId String diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2df331d..2dfad1d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module'; import { TodayModule } from './modules/today/today.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { RealtimeModule } from './realtime/realtime.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul OrganizationModule, TodayModule, NotificationsModule, + RealtimeModule, AdminModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 0924351..aa11e82 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { createReadStream, existsSync } from 'fs'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { LabCaseAccessService } from './lab-case-access.service'; const labCaseListInclude = { @@ -96,6 +97,7 @@ export class CasesService { private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, private readonly labCaseAccess: LabCaseAccessService, ) {} @@ -385,6 +387,14 @@ export class CasesService { type: LabCaseActivityType.CASE_IMPORTANT, actorUserId, }); + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.CASE_IMPORTANT, + href: `/cases?caseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId }, + requiredPermission: 'TAB_CASES_READ', + }); } const labCase = await this.prisma.labCase.findFirstOrThrow({ @@ -476,6 +486,17 @@ export class CasesService { }, }); + if (assigneeUserId && assigneeUserId !== actorUserId) { + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.TASK_ASSIGNED, + href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId, taskId }, + recipientUserIds: [assigneeUserId], + }); + } + const labCase = await this.prisma.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: labCaseListInclude, diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index 60e5a9d..794f8e5 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -3,12 +3,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client'; +import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; const commentInclude = { authorUser: { select: { id: true, name: true } }, @@ -24,6 +25,7 @@ export class LabCaseCommentsService { constructor( private readonly prisma: PrismaService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} // ---------- Lab side (TAB_TASKS_EDIT) ---------- @@ -67,6 +69,31 @@ export class LabCaseCommentsService { visibleToClinic: created.visibleToClinic, }, }); + + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.LAB_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_TASKS_READ', + }); + + if (created.visibleToClinic) { + const clinicOrgId = await this.clinicOrgIdForCase(caseId); + if (clinicOrgId) { + void this.userNotifications.notify({ + organizationId: clinicOrgId, + type: UserNotificationType.LAB_COMMENT_CLINIC, + href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_TREATMENT_READ', + labCaseIdForProviderScope: caseId, + }); + } + } + return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) }; } @@ -130,6 +157,17 @@ export class LabCaseCommentsService { actorUserId, payload: { commentId: created.id }, }); + const labOrgId = await this.labOrgIdForCase(caseId); + if (labOrgId) { + void this.userNotifications.notify({ + organizationId: labOrgId, + type: UserNotificationType.CLINIC_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_CASES_READ', + }); + } return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } @@ -172,6 +210,17 @@ export class LabCaseCommentsService { actorUserId, payload: { commentId: created.id }, }); + const labOrgId = await this.labOrgIdForCase(caseId); + if (labOrgId) { + void this.userNotifications.notify({ + organizationId: labOrgId, + type: UserNotificationType.CLINIC_COMMENT, + href: `/cases?caseId=${encodeURIComponent(caseId)}`, + actorUserId, + payload: { labCaseId: caseId, commentId: created.id }, + requiredPermission: 'TAB_CASES_READ', + }); + } return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } @@ -336,4 +385,21 @@ export class LabCaseCommentsService { } throw new ForbiddenException('You do not have access to treatment cases'); } + + private async labOrgIdForCase(caseId: string): Promise { + 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 { + const labCase = await this.prisma.labCase.findUnique({ + where: { id: caseId }, + select: { treatment: { select: { organizationId: true } } }, + }); + return labCase?.treatment.organizationId ?? null; + } } diff --git a/backend/src/modules/notifications/dto/notifications.dto.ts b/backend/src/modules/notifications/dto/notifications.dto.ts index 90f8667..98cf103 100644 --- a/backend/src/modules/notifications/dto/notifications.dto.ts +++ b/backend/src/modules/notifications/dto/notifications.dto.ts @@ -1,4 +1,5 @@ -import { IsEnum, IsUUID } from 'class-validator'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; import { LabCaseTabReadTarget } from '@prisma/client'; export class MarkTabReadDto { @@ -10,3 +11,16 @@ export class MarkCaseReadDto { @IsUUID() labCaseId!: string; } + +export class ListInboxQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number; + + @IsOptional() + @IsString() + cursor?: string; +} diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts index d040e8f..d175b21 100644 --- a/backend/src/modules/notifications/notifications.controller.ts +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -1,15 +1,34 @@ -import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto'; +import { + ListInboxQueryDto, + MarkCaseReadDto, + MarkTabReadDto, +} from './dto/notifications.dto'; import { LabCaseActivityService } from './lab-case-activity.service'; +import { UserNotificationService } from './user-notification.service'; @ApiTags('notifications') @ApiBearerAuth('JWT-auth') @UseGuards(JwtAuthGuard) @Controller('notifications') export class NotificationsController { - constructor(private readonly labCaseActivityService: LabCaseActivityService) {} + constructor( + private readonly labCaseActivityService: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, + ) {} @Get('tab-counts') @ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' }) @@ -69,4 +88,53 @@ export class NotificationsController { dto.labCaseId, ); } + + @Get('inbox') + @ApiOperation({ summary: 'Paginated notification inbox for the current user and org' }) + listInbox( + @Query() query: ListInboxQueryDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: { items: [], nextCursor: null } }; + } + return this.userNotifications.listInbox(req.user.id, organizationId, { + limit: query.limit, + cursor: query.cursor, + }); + } + + @Get('inbox/unread-count') + @ApiOperation({ summary: 'Unread inbox count for the header bell' }) + inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: { count: 0 } }; + } + return this.userNotifications.unreadCount(req.user.id, organizationId); + } + + @Post('inbox/read-all') + @ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' }) + markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true }; + } + return this.userNotifications.markAllRead(req.user.id, organizationId); + } + + @Post('inbox/:id/read') + @ApiOperation({ summary: 'Mark a single inbox notification as read' }) + markInboxRead( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = req.user.organizationId; + if (!organizationId) { + return { success: true, data: null }; + } + return this.userNotifications.markRead(req.user.id, organizationId, id); + } } diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts index 805af2e..3314a38 100644 --- a/backend/src/modules/notifications/notifications.module.ts +++ b/backend/src/modules/notifications/notifications.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { LabCaseActivityService } from './lab-case-activity.service'; import { NotificationsController } from './notifications.controller'; +import { UserNotificationService } from './user-notification.service'; +import { RealtimeModule } from '../../realtime/realtime.module'; @Module({ + imports: [RealtimeModule], controllers: [NotificationsController], - providers: [LabCaseActivityService], - exports: [LabCaseActivityService], + providers: [LabCaseActivityService, UserNotificationService], + exports: [LabCaseActivityService, UserNotificationService], }) export class NotificationsModule {} diff --git a/backend/src/modules/notifications/user-notification.service.ts b/backend/src/modules/notifications/user-notification.service.ts new file mode 100644 index 0000000..a3b4d0b --- /dev/null +++ b/backend/src/modules/notifications/user-notification.service.ts @@ -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 | null; + readAt: string | null; + createdAt: string; + actorUserId: string | null; +}; + +type FanoutInput = { + organizationId: string; + type: UserNotificationType; + href: string; + actorUserId?: string | null; + payload?: Record | 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 { + 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 { + 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) + : null, + readAt: row.readAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + actorUserId: row.actorUserId, + }; + } + + private async resolveRecipients(input: FanoutInput): Promise { + 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)]; + } +} diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index 18a7d93..723212e 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { CasesModule } from '../cases/cases.module'; import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { OrganizationController } from './organization.controller'; import { OrganizationService } from './organization.service'; @Module({ - imports: [CasesModule, LabCaseCommentsModule], + imports: [CasesModule, LabCaseCommentsModule, NotificationsModule], controllers: [OrganizationController], providers: [OrganizationService, PrismaService], }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 65b2184..c4dcd59 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -5,7 +5,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LinkStatus } from '@prisma/client'; +import { LinkStatus, UserNotificationType } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto'; import { CasesService } from '../cases/cases.service'; import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; @@ -36,6 +37,7 @@ export class OrganizationService { private readonly prisma: PrismaService, private readonly casesService: CasesService, private readonly commentsService: LabCaseCommentsService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -269,6 +271,15 @@ export class OrganizationService { }, }); + void this.userNotifications.notify({ + organizationId: dto.targetOrganizationId, + type: UserNotificationType.CONNECTION_REQUEST, + href: '/organizations', + actorUserId: userId, + payload: { organizationLinkId: created.id, fromOrganizationId: organizationId }, + requiredPermission: 'TAB_ORGANIZATIONS_READ', + }); + return { success: true, data: { id: created.id, status: created.status }, diff --git a/backend/src/modules/staff/staff.module.ts b/backend/src/modules/staff/staff.module.ts index 0fefc9c..9258e96 100644 --- a/backend/src/modules/staff/staff.module.ts +++ b/backend/src/modules/staff/staff.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { NotificationsModule } from '../notifications/notifications.module'; import { StaffController } from './staff.controller'; import { StaffService } from './staff.service'; import { StaffWorkingHoursService } from './staff-working-hours.service'; @Module({ + imports: [NotificationsModule], controllers: [StaffController], providers: [StaffService, StaffWorkingHoursService, PrismaService], exports: [StaffWorkingHoursService], diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index eb476b8..f7bb2e4 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; -import { Prisma } from '@prisma/client'; +import { Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; @@ -17,10 +17,14 @@ import { } from '../../common/organization-type'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { UserNotificationService } from '../notifications/user-notification.service'; @Injectable() export class StaffService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly userNotifications: UserNotificationService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -217,6 +221,19 @@ export class StaffService { }; }); + void this.userNotifications.notify({ + organizationId, + type: UserNotificationType.STAFF_INVITE, + href: '/staff', + actorUserId: userId, + payload: { + membershipId: result.membershipId, + staffInvitationId: result.invitationId, + email, + }, + requiredPermission: 'TAB_STAFF_READ', + }); + return { success: true, data: { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index be94074..9a84300 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -16,6 +16,7 @@ import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date' import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; const taskListInclude = { lastStatusChangedBy: { select: { id: true, name: true } }, @@ -42,6 +43,7 @@ export class TasksService { private readonly prisma: PrismaService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -323,6 +325,30 @@ export class TasksService { return result; }); + if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) { + void this.userNotifications.notify({ + organizationId: labOrganizationId, + type: UserNotificationType.TASK_COMPLETED, + href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`, + actorUserId, + payload: { labCaseId: task.labCaseId, taskId }, + requiredPermission: 'TAB_TASKS_READ', + }); + + const clinicOrgId = task.labCase?.treatment?.organization?.id; + if (clinicOrgId) { + void this.userNotifications.notify({ + organizationId: clinicOrgId, + type: UserNotificationType.TASK_COMPLETED, + href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`, + actorUserId, + payload: { labCaseId: task.labCaseId, taskId }, + requiredPermission: 'TAB_TREATMENT_READ', + labCaseIdForProviderScope: task.labCaseId, + }); + } + } + const locale = normalizeCatalogLocale(localeInput); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 6ea3cc4..4572ea4 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; -import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client'; +import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -27,6 +27,7 @@ import { } from '../../common/lab-case-due-date'; import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { UserNotificationService } from '../notifications/user-notification.service'; import { generateTreatmentTitle, normalizeTeeth, @@ -130,6 +131,7 @@ export class TreatmentsService { private readonly treatmentCatalog: TreatmentCatalogService, private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly labCaseActivity: LabCaseActivityService, + private readonly userNotifications: UserNotificationService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -815,6 +817,17 @@ export class TreatmentsService { } }); + if (isFirstSend && labCase.destinationOrganizationId) { + void this.userNotifications.notify({ + organizationId: labCase.destinationOrganizationId, + type: UserNotificationType.CASE_SENT, + href: `/cases?caseId=${encodeURIComponent(labCaseId)}`, + actorUserId, + payload: { labCaseId }, + requiredPermission: 'TAB_CASES_READ', + }); + } + const refreshed = await this.prisma.labCase.findUniqueOrThrow({ where: { id: labCaseId }, include: { diff --git a/backend/src/realtime/realtime.emitter.ts b/backend/src/realtime/realtime.emitter.ts new file mode 100644 index 0000000..31f324d --- /dev/null +++ b/backend/src/realtime/realtime.emitter.ts @@ -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); + } +} diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts new file mode 100644 index 0000000..e1e0ad1 --- /dev/null +++ b/backend/src/realtime/realtime.gateway.ts @@ -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(token, { + secret: this.config.get('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; + } +} diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts new file mode 100644 index 0000000..e32fd2d --- /dev/null +++ b/backend/src/realtime/realtime.module.ts @@ -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('jwt.secret'), + }), + }), + ], + providers: [RealtimeEmitter, RealtimeGateway], + exports: [RealtimeEmitter], +}) +export class RealtimeModule {} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 000816c..849cd6e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -968,6 +968,30 @@ "monthNovember": "November", "monthDecember": "December" }, + "notifications": { + "bellAria": "Notifications", + "unreadCount": "{count} unread notifications", + "dropdownTitle": "Notifications", + "viewAll": "View all", + "pageTitle": "Notifications", + "pageSubtitle": "Updates from cases, tasks, organizations, and staff.", + "loading": "Loading notifications…", + "empty": "No notifications yet.", + "loadMore": "Load more", + "markAllRead": "Mark all as read", + "errorLoad": "Could not load notifications.", + "errorMarkRead": "Could not update notification.", + "typeCaseSent": "New lab case received", + "typeClinicComment": "New clinic comment on a case", + "typeLabComment": "New lab comment on a case", + "typeLabCommentClinic": "New lab comment on your case", + "typeCaseImportant": "Case marked as important", + "typeTaskCompleted": "Lab task completed", + "typeTaskAssigned": "A task was assigned to you", + "typeConnectionRequest": "New organization connection request", + "typeStaffInvite": "Staff invitation created", + "typeUnknown": "Notification" + }, "errors": { "GENERIC": "Something went wrong. Please try again.", "NETWORK_ERROR": "Could not reach the server. Check your connection and try again.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 45ca4a7..c3a1488 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -969,6 +969,30 @@ "monthNovember": "نوامبر", "monthDecember": "دسامبر" }, + "notifications": { + "bellAria": "اعلان‌ها", + "unreadCount": "{count} اعلان خوانده‌نشده", + "dropdownTitle": "اعلان‌ها", + "viewAll": "مشاهده همه", + "pageTitle": "اعلان‌ها", + "pageSubtitle": "به‌روزرسانی‌های پرونده‌ها، وظایف، سازمان‌ها و کارکنان.", + "loading": "در حال بارگذاری اعلان‌ها…", + "empty": "هنوز اعلانی نیست.", + "loadMore": "بیشتر", + "markAllRead": "علامت‌گذاری همه به‌عنوان خوانده‌شده", + "errorLoad": "بارگذاری اعلان‌ها ممکن نشد.", + "errorMarkRead": "به‌روزرسانی اعلان ممکن نشد.", + "typeCaseSent": "پرونده جدید در لابراتوار دریافت شد", + "typeClinicComment": "نظر جدید کلینیک روی پرونده", + "typeLabComment": "نظر جدید لابراتوار روی پرونده", + "typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما", + "typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد", + "typeTaskCompleted": "وظیفه لابراتوار تکمیل شد", + "typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد", + "typeConnectionRequest": "درخواست اتصال سازمان جدید", + "typeStaffInvite": "دعوتنامه کارکنان ایجاد شد", + "typeUnknown": "اعلان" + }, "errors": { "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", "NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index ca0c221..58ca917 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -968,6 +968,30 @@ "monthNovember": "November", "monthDecember": "December" }, + "notifications": { + "bellAria": "Meldingen", + "unreadCount": "{count} ongelezen meldingen", + "dropdownTitle": "Meldingen", + "viewAll": "Alles bekijken", + "pageTitle": "Meldingen", + "pageSubtitle": "Updates van cases, taken, organisaties en personeel.", + "loading": "Meldingen laden…", + "empty": "Nog geen meldingen.", + "loadMore": "Meer laden", + "markAllRead": "Alles als gelezen markeren", + "errorLoad": "Meldingen konden niet worden geladen.", + "errorMarkRead": "Melding kon niet worden bijgewerkt.", + "typeCaseSent": "Nieuwe labcase ontvangen", + "typeClinicComment": "Nieuwe kliniekreactie op een case", + "typeLabComment": "Nieuwe labreactie op een case", + "typeLabCommentClinic": "Nieuwe labreactie op uw case", + "typeCaseImportant": "Case gemarkeerd als belangrijk", + "typeTaskCompleted": "Labtaak voltooid", + "typeTaskAssigned": "Er is een taak aan u toegewezen", + "typeConnectionRequest": "Nieuw organisatieverzoek", + "typeStaffInvite": "Personeelsuitnodiging aangemaakt", + "typeUnknown": "Melding" + }, "errors": { "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", "NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 89c31d7..5159052 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "react-hook-form": "^7.71.2", "react-qr-code": "^2.0.15", "recharts": "^3.9.2", + "socket.io-client": "^4.8.3", "zod": "^4.3.6" }, "devDependencies": { @@ -1627,6 +1628,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3614,7 +3621,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3736,6 +3742,28 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5962,7 +5990,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -7096,6 +7123,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7844,6 +7899,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0ede802..44256a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "react-hook-form": "^7.71.2", "react-qr-code": "^2.0.15", "recharts": "^3.9.2", + "socket.io-client": "^4.8.3", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index 7947c0b..4c211da 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; +import { NotificationBell } from '@/components/ui/notifications/NotificationBell'; import { ToastProvider } from '@/components/ui/shared/ToastProvider'; +import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider'; import { canAccessDashboardRoute, firstAccessibleDashboardPath, @@ -87,6 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return ( +
{sidebarOpen ? (
+
); } @@ -141,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
+
diff --git a/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx b/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..6490613 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/notifications/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage'; + +export default function NotificationsRoutePage() { + return ; +} diff --git a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx index a9c4d5d..bc49ade 100644 --- a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx @@ -10,6 +10,7 @@ export default function TreatmentPage() { const { user, currentOrganization, isAuthReady } = useAuth(); const searchParams = useSearchParams(); const initialAppointmentId = searchParams.get('appointmentId'); + const initialLabCaseId = searchParams.get('labCaseId'); if (!isAuthReady || !user) { return ( @@ -22,6 +23,7 @@ export default function TreatmentPage() { userId={user.id} currentOrganization={currentOrganization} initialAppointmentId={initialAppointmentId} + initialLabCaseId={initialLabCaseId} /> ); } diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index ef54efc..d3179f7 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -173,6 +173,32 @@ export function TasksPage() { setPage(1); }, [searchParams]); + useEffect(() => { + const taskId = searchParams.get('taskId')?.trim(); + if (!taskId || !canView) return; + let cancelled = false; + void (async () => { + try { + const response = await tasksApi.locatePage(buildDefaultLocateParams(taskId, PAGE_SIZE)); + if (cancelled || !response.data.found) return; + setStatusFilter(''); + setImportantOnly(false); + setOverdueOnly(false); + setUnassignedOnly(false); + setProsthesisTypeCode(''); + setSortBy('date'); + setSortDir('desc'); + setPage(response.data.page); + setHighlightTaskId(taskId); + } catch { + /* ignore deep-link locate failures */ + } + })(); + return () => { + cancelled = true; + }; + }, [searchParams, canView]); + const loadTasks = useCallback(async () => { setLoading(true); setError(''); diff --git a/frontend/src/components/ui/notifications/NotificationBell.tsx b/frontend/src/components/ui/notifications/NotificationBell.tsx new file mode 100644 index 0000000..626f8e6 --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationBell.tsx @@ -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([]); + const [unreadCount, setLocalUnread] = useState(0); + const [loading, setLoading] = useState(false); + const menuRef = useRef(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 ( +
+ + + {open ? ( +
+
+

{t('dropdownTitle')}

+ setOpen(false)} + > + {t('viewAll')} + +
+
+ {loading && items.length === 0 ? ( +

{t('loading')}

+ ) : items.length === 0 ? ( +

{t('empty')}

+ ) : ( + items.map((item) => ( + + )) + )} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/notifications/NotificationCard.tsx b/frontend/src/components/ui/notifications/NotificationCard.tsx new file mode 100644 index 0000000..41247c6 --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationCard.tsx @@ -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 = { + 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 ( + + ); +} diff --git a/frontend/src/components/ui/notifications/NotificationsPage.tsx b/frontend/src/components/ui/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..dd2e28b --- /dev/null +++ b/frontend/src/components/ui/notifications/NotificationsPage.tsx @@ -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([]); + const [nextCursor, setNextCursor] = useState(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 ( +
+
+
+

{t('pageTitle')}

+

{t('pageSubtitle')}

+
+ +
+ +
+ {loading ? ( +

{t('loading')}

+ ) : items.length === 0 ? ( +

{t('empty')}

+ ) : ( + items.map((item) => ( + void handleSelect(row)} /> + )) + )} +
+ + {nextCursor ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index f9a400b..811e118 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -318,12 +318,14 @@ interface TreatmentWorkspaceProps { userId: string; currentOrganization: Organization | null; initialAppointmentId?: string | null; + initialLabCaseId?: string | null; } export function TreatmentWorkspace({ userId, currentOrganization, initialAppointmentId = null, + initialLabCaseId = null, }: TreatmentWorkspaceProps) { const locale = useLocale(); const t = useTranslations('treatment'); @@ -404,6 +406,7 @@ export function TreatmentWorkspace({ labCaseDraftsRef.current = labCaseDrafts; const skipNextGetDraftRef = useRef(false); const pendingAppointmentIdRef = useRef(initialAppointmentId); + const pendingLabCaseIdRef = useRef(initialLabCaseId); const labPanelRef = useRef(null); const historyRequestRef = useRef(0); /** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */ @@ -417,6 +420,10 @@ export function TreatmentWorkspace({ } }, [initialAppointmentId]); + useEffect(() => { + pendingLabCaseIdRef.current = initialLabCaseId; + }, [initialLabCaseId]); + const [sendBusyId, setSendBusyId] = useState(null); const [uploadBusyDetailId, setUploadBusyDetailId] = useState(null); const [organizationSearch, setOrganizationSearch] = useState(''); @@ -1351,6 +1358,17 @@ export function TreatmentWorkspace({ ], ); + useEffect(() => { + const labCaseId = pendingLabCaseIdRef.current; + if (!labCaseId) return; + const match = + unreadLabCases.find((item) => item.labCaseId === labCaseId) ?? + patientLabCases.find((item) => item.labCaseId === labCaseId); + if (!match) return; + pendingLabCaseIdRef.current = null; + void handleSelectPatientLabCase(match); + }, [unreadLabCases, patientLabCases, handleSelectPatientLabCase]); + useEffect(() => { const match = patientLabCases.find((item) => item.detailClientId === activeDetailId); if (match) { diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 4f4ed3c..3fca449 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/lib/api/client'; +import type { InboxPage, UserNotificationItem } from '@/types/notifications'; import type { LabCaseActivityItem } from '@/types/lab-case-activity'; import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils'; @@ -27,4 +28,29 @@ export const notificationsApi = { const response = await apiClient.post('/notifications/mark-case-read', { labCaseId }); return response.data; }, + + listInbox: async (params?: { + limit?: number; + cursor?: string; + }): Promise<{ success: boolean; data: InboxPage }> => { + const response = await apiClient.get('/notifications/inbox', { params }); + return response.data; + }, + + inboxUnreadCount: async (): Promise<{ success: boolean; data: { count: number } }> => { + const response = await apiClient.get('/notifications/inbox/unread-count'); + return response.data; + }, + + markInboxRead: async ( + id: string, + ): Promise<{ success: boolean; data: UserNotificationItem | null }> => { + const response = await apiClient.post(`/notifications/inbox/${id}/read`); + return response.data; + }, + + markInboxReadAll: async (): Promise<{ success: boolean }> => { + const response = await apiClient.post('/notifications/inbox/read-all'); + return response.data; + }, }; diff --git a/frontend/src/lib/realtime/RealtimeProvider.tsx b/frontend/src/lib/realtime/RealtimeProvider.tsx new file mode 100644 index 0000000..978b290 --- /dev/null +++ b/frontend/src/lib/realtime/RealtimeProvider.tsx @@ -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({ + 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(null); + const [unreadCount, setUnreadCount] = useState(null); + const socketRef = useRef(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 {children}; +} + +export function useRealtime() { + return useContext(RealtimeContext); +} diff --git a/frontend/src/types/notifications.ts b/frontend/src/types/notifications.ts new file mode 100644 index 0000000..390390a --- /dev/null +++ b/frontend/src/types/notifications.ts @@ -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 | null; + readAt: string | null; + createdAt: string; + actorUserId: string | null; +}; + +export type InboxPage = { + items: UserNotificationItem[]; + nextCursor: string | null; +};