feature: version one notification feature implemented.

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

View File

@@ -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",

View File

@@ -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"
},

View File

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

View File

@@ -30,6 +30,8 @@ model User {
labCaseComments LabCaseComment[]
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

View File

@@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
import { 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],

View File

@@ -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,

View File

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

View File

@@ -1,4 +1,5 @@
import { IsEnum, IsUUID } from 'class-validator';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { LabCaseTabReadTarget } from '@prisma/client';
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;
}

View File

@@ -1,15 +1,34 @@
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { 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);
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { RealtimeEmitter } from '../../realtime/realtime.emitter';
const membershipInclude = {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
} satisfies Prisma.MembershipInclude;
export type UserNotificationDto = {
id: string;
type: UserNotificationType;
href: string;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
actorUserId: string | null;
};
type FanoutInput = {
organizationId: string;
type: UserNotificationType;
href: string;
actorUserId?: string | null;
payload?: Record<string, unknown> | null;
/** Explicit recipient user ids (e.g. task assignee). Skips permission fan-out. */
recipientUserIds?: string[];
/** Required tab permission when resolving org members. */
requiredPermission?: string;
/** When set, only clinic members who are the treatment provider for this case. */
labCaseIdForProviderScope?: string;
};
@Injectable()
export class UserNotificationService {
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeEmitter,
) {}
async notify(input: FanoutInput): Promise<void> {
const recipientIds = input.recipientUserIds?.length
? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))]
: await this.resolveRecipients(input);
if (recipientIds.length === 0) return;
const rows = await this.prisma.userNotification.createManyAndReturn({
data: recipientIds.map((userId) => ({
userId,
organizationId: input.organizationId,
type: input.type,
actorUserId: input.actorUserId ?? null,
payload: (input.payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
href: input.href,
})),
});
for (const row of rows) {
const dto = this.mapRow(row);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.created', {
notification: dto,
});
const unreadCount = await this.countUnread(row.userId, row.organizationId);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.unreadCount', {
count: unreadCount,
});
}
}
async listInbox(
userId: string,
organizationId: string,
options?: { limit?: number; cursor?: string },
) {
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 50);
const rows = await this.prisma.userNotification.findMany({
where: { userId, organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
...(options?.cursor
? {
cursor: { id: options.cursor },
skip: 1,
}
: {}),
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
return {
success: true as const,
data: {
items: page.map((row) => this.mapRow(row)),
nextCursor,
},
};
}
async unreadCount(userId: string, organizationId: string) {
const count = await this.countUnread(userId, organizationId);
return { success: true as const, data: { count } };
}
async markRead(userId: string, organizationId: string, notificationId: string) {
const existing = await this.prisma.userNotification.findFirst({
where: { id: notificationId, userId, organizationId },
});
if (!existing) {
return { success: true as const, data: null };
}
if (!existing.readAt) {
const updated = await this.prisma.userNotification.update({
where: { id: notificationId },
data: { readAt: new Date() },
});
const count = await this.countUnread(userId, organizationId);
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count,
});
return { success: true as const, data: this.mapRow(updated) };
}
return { success: true as const, data: this.mapRow(existing) };
}
async markAllRead(userId: string, organizationId: string) {
await this.prisma.userNotification.updateMany({
where: { userId, organizationId, readAt: null },
data: { readAt: new Date() },
});
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count: 0,
});
return { success: true as const };
}
private async countUnread(userId: string, organizationId: string): Promise<number> {
return this.prisma.userNotification.count({
where: { userId, organizationId, readAt: null },
});
}
private mapRow(row: {
id: string;
type: UserNotificationType;
href: string;
payload: Prisma.JsonValue;
readAt: Date | null;
createdAt: Date;
actorUserId: string | null;
}): UserNotificationDto {
return {
id: row.id,
type: row.type,
href: row.href,
payload:
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>)
: null,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
actorUserId: row.actorUserId,
};
}
private async resolveRecipients(input: FanoutInput): Promise<string[]> {
if (!input.requiredPermission) return [];
const memberships = await this.prisma.membership.findMany({
where: {
organizationId: input.organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: membershipInclude,
});
let userIds = memberships
.filter((m) => hasEffectivePermission(m, input.requiredPermission!))
.map((m) => m.userId)
.filter((id) => id !== input.actorUserId);
if (input.labCaseIdForProviderScope) {
const labCase = await this.prisma.labCase.findUnique({
where: { id: input.labCaseIdForProviderScope },
select: {
treatment: {
select: {
providerUserId: true,
appointment: { select: { providerUserId: true } },
},
},
},
});
if (!labCase?.treatment) return [];
userIds = userIds.filter((userId) =>
isActorTreatmentProvider(labCase.treatment, userId),
);
}
return [...new Set(userIds)];
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { 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],
})

View File

@@ -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 },

View File

@@ -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],

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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: {

View File

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

View File

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

View File

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