From 6b6836478570d4b7eb0583b6ae411c6758b4dcd1 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Mon, 23 Feb 2026 19:11:09 +0530 Subject: [PATCH] enhanced worknote service and create service for worknote live chat and inapp notificatio, also addd api for audit logs --- package-lock.json | 353 +++++++++++++++++- package.json | 3 + scripts/cleanup-interview-orphans.ts | 26 ++ src/common/config/constants.ts | 70 +++- src/common/utils/notification.service.ts | 108 ++++++ src/common/utils/socket.ts | 51 +++ src/database/models/Application.ts | 6 + src/database/models/ConstitutionalChange.ts | 3 +- src/database/models/Document.ts | 7 +- src/database/models/PushSubscription.ts | 62 +++ src/database/models/RelocationRequest.ts | 3 +- src/database/models/Resignation.ts | 3 +- src/database/models/Worknote.ts | 7 +- src/database/models/index.ts | 2 + src/modules/audit/audit.controller.ts | 233 ++++++++++++ src/modules/audit/audit.routes.ts | 15 + .../collaboration/collaboration.controller.ts | 185 ++++++++- .../collaboration/collaboration.routes.ts | 2 + .../communication/communication.controller.ts | 49 +++ .../communication/communication.routes.ts | 3 + src/modules/master/master.controller.ts | 34 +- .../onboarding/onboarding.controller.ts | 2 +- src/scripts/drop_worknote_constraint.ts | 15 + src/server.ts | 22 +- 24 files changed, 1246 insertions(+), 18 deletions(-) create mode 100644 scripts/cleanup-interview-orphans.ts create mode 100644 src/common/utils/notification.service.ts create mode 100644 src/common/utils/socket.ts create mode 100644 src/database/models/PushSubscription.ts create mode 100644 src/modules/audit/audit.controller.ts create mode 100644 src/modules/audit/audit.routes.ts create mode 100644 src/scripts/drop_worknote_constraint.ts diff --git a/package-lock.json b/package-lock.json index 4158b02..cf38248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,9 @@ "pg": "^8.18.0", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", + "socket.io": "^4.8.3", "uuid": "^13.0.0", + "web-push": "^3.6.7", "winston": "^3.19.0" }, "devDependencies": { @@ -41,6 +43,7 @@ "@types/supertest": "^6.0.3", "@types/uuid": "^10.0.0", "@types/validator": "^13.15.10", + "@types/web-push": "^3.6.4", "jest": "^30.2.0", "nodemon": "^3.0.2", "supertest": "^7.2.2", @@ -3018,6 +3021,12 @@ "text-hex": "1.0.x" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -3152,7 +3161,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3388,6 +3396,16 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "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", @@ -3745,6 +3763,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3834,6 +3861,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3953,6 +3992,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/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.9.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", @@ -3985,6 +4033,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4838,6 +4892,80 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "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.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5666,6 +5794,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5686,6 +5823,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7015,6 +7188,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8311,6 +8490,138 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/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.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/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.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9078,6 +9389,25 @@ "makeerror": "1.0.12" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9260,6 +9590,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index de1b14d..5832f0c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "pg": "^8.18.0", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", + "socket.io": "^4.8.3", "uuid": "^13.0.0", + "web-push": "^3.6.7", "winston": "^3.19.0" }, "devDependencies": { @@ -56,6 +58,7 @@ "@types/supertest": "^6.0.3", "@types/uuid": "^10.0.0", "@types/validator": "^13.15.10", + "@types/web-push": "^3.6.4", "jest": "^30.2.0", "nodemon": "^3.0.2", "supertest": "^7.2.2", diff --git a/scripts/cleanup-interview-orphans.ts b/scripts/cleanup-interview-orphans.ts new file mode 100644 index 0000000..3d948d0 --- /dev/null +++ b/scripts/cleanup-interview-orphans.ts @@ -0,0 +1,26 @@ +import db from '../src/database/models/index.js'; + +async function cleanup() { + try { + await db.sequelize.authenticate(); + console.log('Database connected.'); + + const [results1]: any = await db.sequelize.query(` + DELETE FROM interview_participants + WHERE "interviewId" NOT IN (SELECT id FROM interviews) + `); + + const [results2]: any = await db.sequelize.query(` + DELETE FROM interview_evaluations + WHERE "interviewId" NOT IN (SELECT id FROM interviews) + `); + + console.log('Cleanup finished.'); + process.exit(0); + } catch (err) { + console.error('Cleanup failed:', err); + process.exit(1); + } +} + +cleanup(); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index ad560b1..702d05a 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -189,15 +189,83 @@ export const FNF_STATUS = { // Audit Actions export const AUDIT_ACTIONS = { + // General CRUD CREATED: 'CREATED', UPDATED: 'UPDATED', APPROVED: 'APPROVED', REJECTED: 'REJECTED', DELETED: 'DELETED', + + // Auth & User Actions LOGIN: 'LOGIN', + LOGOUT: 'LOGOUT', + REGISTERED: 'REGISTERED', + PASSWORD_CHANGED: 'PASSWORD_CHANGED', + PROFILE_UPDATED: 'PROFILE_UPDATED', + + // Application Lifecycle STAGE_CHANGED: 'STAGE_CHANGED', + SHORTLISTED: 'SHORTLISTED', + DISQUALIFIED: 'DISQUALIFIED', + QUESTIONNAIRE_SUBMITTED: 'QUESTIONNAIRE_SUBMITTED', + QUESTIONNAIRE_LINK_SENT: 'QUESTIONNAIRE_LINK_SENT', + + // Documents & Collaboration DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED', - WORKNOTE_ADDED: 'WORKNOTE_ADDED' + DOCUMENT_VERIFIED: 'DOCUMENT_VERIFIED', + WORKNOTE_ADDED: 'WORKNOTE_ADDED', + ATTACHMENT_UPLOADED: 'ATTACHMENT_UPLOADED', + PARTICIPANT_ADDED: 'PARTICIPANT_ADDED', + PARTICIPANT_REMOVED: 'PARTICIPANT_REMOVED', + + // Interviews & Assessment + INTERVIEW_SCHEDULED: 'INTERVIEW_SCHEDULED', + INTERVIEW_UPDATED: 'INTERVIEW_UPDATED', + INTERVIEW_COMPLETED: 'INTERVIEW_COMPLETED', + KT_MATRIX_SUBMITTED: 'KT_MATRIX_SUBMITTED', + FEEDBACK_SUBMITTED: 'FEEDBACK_SUBMITTED', + RECOMMENDATION_UPDATED: 'RECOMMENDATION_UPDATED', + DECISION_MADE: 'DECISION_MADE', + + // FDD + FDD_ASSIGNED: 'FDD_ASSIGNED', + FDD_REPORT_UPLOADED: 'FDD_REPORT_UPLOADED', + + // LOI & LOA + LOI_REQUESTED: 'LOI_REQUESTED', + LOI_APPROVED: 'LOI_APPROVED', + LOI_REJECTED: 'LOI_REJECTED', + LOI_GENERATED: 'LOI_GENERATED', + LOA_REQUESTED: 'LOA_REQUESTED', + LOA_APPROVED: 'LOA_APPROVED', + LOA_GENERATED: 'LOA_GENERATED', + + // EOR + EOR_CHECKLIST_CREATED: 'EOR_CHECKLIST_CREATED', + EOR_ITEM_UPDATED: 'EOR_ITEM_UPDATED', + EOR_AUDIT_SUBMITTED: 'EOR_AUDIT_SUBMITTED', + + // Dealer & Finance + DEALER_CREATED: 'DEALER_CREATED', + DEALER_UPDATED: 'DEALER_UPDATED', + DEALER_CODE_GENERATED: 'DEALER_CODE_GENERATED', + PAYMENT_UPDATED: 'PAYMENT_UPDATED', + SECURITY_DEPOSIT_UPDATED: 'SECURITY_DEPOSIT_UPDATED', + FNF_UPDATED: 'FNF_UPDATED', + + // Admin + USER_CREATED: 'USER_CREATED', + USER_UPDATED: 'USER_UPDATED', + USER_STATUS_CHANGED: 'USER_STATUS_CHANGED', + ROLE_CREATED: 'ROLE_CREATED', + ROLE_UPDATED: 'ROLE_UPDATED', + + // Resignation & Self-Service + RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED', + RESIGNATION_APPROVED: 'RESIGNATION_APPROVED', + RESIGNATION_REJECTED: 'RESIGNATION_REJECTED', + EMAIL_SENT: 'EMAIL_SENT', + REMINDER_SENT: 'REMINDER_SENT' } as const; // Document Types diff --git a/src/common/utils/notification.service.ts b/src/common/utils/notification.service.ts new file mode 100644 index 0000000..5a4ad24 --- /dev/null +++ b/src/common/utils/notification.service.ts @@ -0,0 +1,108 @@ +import webpush from 'web-push'; +import db from '../../database/models/index.js'; +import { getIO } from './socket.js'; +import logger from './logger.js'; + +const { Notification, PushSubscription } = db; + +// Initialize VAPID keys +if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { + webpush.setVapidDetails( + `mailto:${process.env.VAPID_EMAIL || 'admin@royalenfield.com'}`, + process.env.VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ); +} + +/** + * Sends a notification to a specific user via multiple channels. + */ +export const sendNotification = async (params: { + userId: string; + title: string; + message: string; + type?: string; + link?: string; + data?: any; +}) => { + const { userId, title, message, type = 'info', link, data } = params; + logger.info(`Starting sendNotification for user: ${userId}`); + + try { + // 1. Create In-App Notification (Database) + const notification = await Notification.create({ + userId, + title, + message, + type, + link, + isRead: false + }); + logger.info(`Notification created in DB with ID: ${notification.id}`); + + // 2. Real-time update via Socket.io + try { + const io = getIO(); + const roomName = `user_${userId}`; + logger.info(`Emitting real-time notification to room: ${roomName}`); + + // Emit to a private room for the user + io.to(roomName).emit('notification', { + id: notification.id, + title, + message, + type, + link, + createdAt: notification.createdAt + }); + } catch (socketError: any) { + logger.warn(`Socket.io emit failed: ${socketError.message}`); + } + + // 3. Web Push Notification + const subscriptions = await PushSubscription.findAll({ where: { userId } }); + + for (const sub of subscriptions) { + try { + const pushConfig = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth + } + }; + + const payload = JSON.stringify({ + title, + body: message, + icon: '/re-logo.png', // Fallback icon path + data: { + url: link || '/', + ...data + } + }); + + await webpush.sendNotification(pushConfig, payload); + } catch (error: any) { + logger.error(`Error sending Web Push to subscription ${sub.id}:`, error.message); + // If subscription is expired or invalid, remove it + if (error.statusCode === 410 || error.statusCode === 404) { + await sub.destroy(); + logger.info(`Removed invalid push subscription: ${sub.id}`); + } + } + } + + return notification; + } catch (error) { + logger.error('Error in sendNotification:', error); + throw error; + } +}; + +/** + * Notifies multiple users (e.g., participants in a request). + */ +export const notifyUsers = async (userIds: string[], params: any) => { + return Promise.all(userIds.map(userId => sendNotification({ ...params, userId }))); +}; diff --git a/src/common/utils/socket.ts b/src/common/utils/socket.ts new file mode 100644 index 0000000..bfe1a1f --- /dev/null +++ b/src/common/utils/socket.ts @@ -0,0 +1,51 @@ +import { Server as SocketServer } from 'socket.io'; +import { Server as HTTPServer } from 'http'; +import logger from './logger.js'; + +let io: SocketServer | null = null; + +/** + * Initializes the Socket.io server and attaches it to the provided HTTP server. + * @param httpServer The HTTP server to attach Socket.io to. + */ +export const initSocket = (httpServer: HTTPServer) => { + io = new SocketServer(httpServer, { + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:5173', + methods: ['GET', 'POST', 'PUT'], + credentials: true + } + }); + + io.on('connection', (socket) => { + logger.info(`Socket connected: ${socket.id}`); + + // Join room based on requestId (applicationId, etc.) + socket.on('join_room', (roomId: string) => { + socket.join(roomId); + logger.info(`Socket ${socket.id} joined room: ${roomId}`); + }); + + socket.on('leave_room', (roomId: string) => { + socket.leave(roomId); + logger.info(`Socket ${socket.id} left room: ${roomId}`); + }); + + socket.on('disconnect', () => { + logger.info(`Socket disconnected: ${socket.id}`); + }); + }); + + return io; +}; + +/** + * Returns the initialized Socket.io instance. + * @throws Error if Socket.io has not been initialized. + */ +export const getIO = (): SocketServer => { + if (!io) { + throw new Error('Socket.io has not been initialized. Please call initSocket first.'); + } + return io; +}; diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index e00e7b8..91455e1 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -246,6 +246,12 @@ export default (sequelize: Sequelize) => { }); Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' }); + Application.hasMany(models.Worknote, { + foreignKey: 'requestId', + as: 'worknotes', + scope: { requestType: 'application' }, + constraints: false + }); Application.hasMany(models.RequestParticipant, { foreignKey: 'requestId', as: 'participants', diff --git a/src/database/models/ConstitutionalChange.ts b/src/database/models/ConstitutionalChange.ts index 50e5191..bf0afe4 100644 --- a/src/database/models/ConstitutionalChange.ts +++ b/src/database/models/ConstitutionalChange.ts @@ -96,7 +96,8 @@ export default (sequelize: Sequelize) => { ConstitutionalChange.hasMany(models.Worknote, { foreignKey: 'requestId', as: 'worknotes', - scope: { requestType: 'constitutional' } + scope: { requestType: 'constitutional' }, + constraints: false }); }; diff --git a/src/database/models/Document.ts b/src/database/models/Document.ts index 89c6c01..d68c669 100644 --- a/src/database/models/Document.ts +++ b/src/database/models/Document.ts @@ -102,7 +102,12 @@ export default (sequelize: Sequelize) => { Document.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' }); Document.hasMany(models.DocumentVersion, { foreignKey: 'documentId', as: 'versions' }); - Document.hasMany(models.WorkNoteAttachment, { foreignKey: 'documentId', as: 'workNoteAttachments' }); + Document.belongsToMany(models.Worknote, { + through: models.WorkNoteAttachment, + foreignKey: 'documentId', + otherKey: 'noteId', + as: 'workNotes' + }); }; return Document; diff --git a/src/database/models/PushSubscription.ts b/src/database/models/PushSubscription.ts new file mode 100644 index 0000000..460f1bb --- /dev/null +++ b/src/database/models/PushSubscription.ts @@ -0,0 +1,62 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface PushSubscriptionAttributes { + id: string; + userId: string; + endpoint: string; + p256dh: string; + auth: string; + userAgent?: string; +} + +export interface PushSubscriptionInstance extends Model, PushSubscriptionAttributes { } + +export default (sequelize: Sequelize) => { + const PushSubscription = sequelize.define('PushSubscription', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + endpoint: { + type: DataTypes.TEXT, + allowNull: false + }, + p256dh: { + type: DataTypes.STRING, + allowNull: false + }, + auth: { + type: DataTypes.STRING, + allowNull: false + }, + userAgent: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'push_subscriptions', + timestamps: true, + indexes: [ + { fields: ['userId'] }, + { fields: ['endpoint'], unique: true } + ] + }); + + (PushSubscription as any).associate = (models: any) => { + PushSubscription.belongsTo(models.User, { + foreignKey: 'userId', + as: 'user' + }); + }; + + return PushSubscription; +}; diff --git a/src/database/models/RelocationRequest.ts b/src/database/models/RelocationRequest.ts index 9a96c2e..4a60e73 100644 --- a/src/database/models/RelocationRequest.ts +++ b/src/database/models/RelocationRequest.ts @@ -111,7 +111,8 @@ export default (sequelize: Sequelize) => { RelocationRequest.hasMany(models.Worknote, { foreignKey: 'requestId', as: 'worknotes', - scope: { requestType: 'relocation' } + scope: { requestType: 'relocation' }, + constraints: false }); }; diff --git a/src/database/models/Resignation.ts b/src/database/models/Resignation.ts index 3952cde..6ea0aa7 100644 --- a/src/database/models/Resignation.ts +++ b/src/database/models/Resignation.ts @@ -124,7 +124,8 @@ export default (sequelize: Sequelize) => { as: 'worknotes', scope: { requestType: 'resignation' - } + }, + constraints: false }); }; diff --git a/src/database/models/Worknote.ts b/src/database/models/Worknote.ts index 71b6a48..70f054c 100644 --- a/src/database/models/Worknote.ts +++ b/src/database/models/Worknote.ts @@ -71,7 +71,12 @@ export default (sequelize: Sequelize) => { Worknote.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' }); Worknote.hasMany(models.WorkNoteTag, { foreignKey: 'noteId', as: 'tags' }); - Worknote.hasMany(models.WorkNoteAttachment, { foreignKey: 'noteId', as: 'attachments' }); + Worknote.belongsToMany(models.Document, { + through: models.WorkNoteAttachment, + foreignKey: 'noteId', + otherKey: 'documentId', + as: 'attachments' + }); }; return Worknote; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 1e8e59e..86d00fd 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -82,6 +82,7 @@ import createExitFeedback from './ExitFeedback.js'; // Batch 7: Notifications, Logs & Templates import createEmailTemplate from './EmailTemplate.js'; +import createPushSubscription from './PushSubscription.js'; // Batch 8: SLA & TAT Tracking import createSLATracking from './SLATracking.js'; @@ -188,6 +189,7 @@ db.ExitFeedback = createExitFeedback(sequelize); // Batch 7: Notifications, Logs & Templates db.EmailTemplate = createEmailTemplate(sequelize); +db.PushSubscription = createPushSubscription(sequelize); // Batch 8: SLA & TAT Tracking db.SLATracking = createSLATracking(sequelize); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..514707f --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -0,0 +1,233 @@ +import { Request, Response } from 'express'; +import db from '../../database/models/index.js'; +const { AuditLog, User } = db; +import { AuthRequest } from '../../types/express.types.js'; +import { Op } from 'sequelize'; + +// Human-readable descriptions for audit actions +const ACTION_DESCRIPTIONS: Record = { + CREATED: 'Record created', + UPDATED: 'Record updated', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DELETED: 'Record deleted', + LOGIN: 'User logged in', + LOGOUT: 'User logged out', + REGISTERED: 'User registered', + PASSWORD_CHANGED: 'Password changed', + PROFILE_UPDATED: 'Profile updated', + STAGE_CHANGED: 'Stage changed', + SHORTLISTED: 'Application shortlisted', + DISQUALIFIED: 'Application disqualified', + QUESTIONNAIRE_SUBMITTED: 'Questionnaire response submitted', + QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant', + DOCUMENT_UPLOADED: 'Document uploaded', + DOCUMENT_VERIFIED: 'Document verified', + WORKNOTE_ADDED: 'Work note added', + ATTACHMENT_UPLOADED: 'Attachment uploaded', + PARTICIPANT_ADDED: 'Participant added', + PARTICIPANT_REMOVED: 'Participant removed', + INTERVIEW_SCHEDULED: 'Interview scheduled', + INTERVIEW_UPDATED: 'Interview updated', + INTERVIEW_COMPLETED: 'Interview completed', + KT_MATRIX_SUBMITTED: 'KT Matrix score submitted', + FEEDBACK_SUBMITTED: 'Interview feedback submitted', + RECOMMENDATION_UPDATED: 'Recommendation updated', + DECISION_MADE: 'Interview decision made', + FDD_ASSIGNED: 'FDD agency assigned', + FDD_REPORT_UPLOADED: 'FDD report uploaded', + LOI_REQUESTED: 'LOI requested', + LOI_APPROVED: 'LOI approved', + LOI_REJECTED: 'LOI rejected', + LOI_GENERATED: 'LOI document generated', + LOA_REQUESTED: 'LOA requested', + LOA_APPROVED: 'LOA approved', + LOA_GENERATED: 'LOA document generated', + EOR_CHECKLIST_CREATED: 'EOR checklist initiated', + EOR_ITEM_UPDATED: 'EOR checklist item updated', + EOR_AUDIT_SUBMITTED: 'EOR audit submitted', + DEALER_CREATED: 'Dealer profile created', + DEALER_UPDATED: 'Dealer profile updated', + DEALER_CODE_GENERATED: 'Dealer code generated', + PAYMENT_UPDATED: 'Payment record updated', + SECURITY_DEPOSIT_UPDATED: 'Security deposit updated', + FNF_UPDATED: 'F&F settlement updated', + USER_CREATED: 'User account created', + USER_UPDATED: 'User account updated', + USER_STATUS_CHANGED: 'User status changed', + ROLE_CREATED: 'Role created', + ROLE_UPDATED: 'Role updated', + RESIGNATION_SUBMITTED: 'Resignation submitted', + RESIGNATION_APPROVED: 'Resignation approved', + RESIGNATION_REJECTED: 'Resignation rejected', + EMAIL_SENT: 'Email notification sent', + REMINDER_SENT: 'Reminder sent' +}; + +/** + * Get audit logs for a specific entity (e.g., application) + * Query params: entityType, entityId, page, limit + */ +export const getAuditLogs = async (req: AuthRequest, res: Response) => { + try { + const { entityType, entityId, page = '1', limit = '50' } = req.query; + + if (!entityType || !entityId) { + return res.status(400).json({ + success: false, + message: 'entityType and entityId are required' + }); + } + + const pageNum = Math.max(1, parseInt(page as string)); + const limitNum = Math.min(100, Math.max(1, parseInt(limit as string))); + const offset = (pageNum - 1) * limitNum; + + const { count, rows: logs } = await AuditLog.findAndCountAll({ + where: { + entityType: entityType as string, + entityId: entityId as string + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'fullName', 'email'] + }], + order: [['createdAt', 'DESC']], + limit: limitNum, + offset + }); + + // Format the response with human-readable descriptions + const formattedLogs = logs.map((log: any) => { + const logData = log.toJSON ? log.toJSON() : log; + return { + id: logData.id, + action: logData.action, + description: ACTION_DESCRIPTIONS[logData.action] || logData.action, + entityType: logData.entityType, + entityId: logData.entityId, + userName: logData.user?.fullName || 'System', + userEmail: logData.user?.email || null, + oldData: logData.oldData, + newData: logData.newData, + changes: formatChanges(logData.oldData, logData.newData), + ipAddress: logData.ipAddress, + timestamp: logData.createdAt + }; + }); + + res.json({ + success: true, + data: formattedLogs, + pagination: { + total: count, + page: pageNum, + limit: limitNum, + totalPages: Math.ceil(count / limitNum) + } + }); + } catch (error) { + console.error('Get audit logs error:', error); + res.status(500).json({ success: false, message: 'Error fetching audit logs' }); + } +}; + +/** + * Get audit log summary/stats for an entity + */ +export const getAuditSummary = async (req: AuthRequest, res: Response) => { + try { + const { entityType, entityId } = req.query; + + if (!entityType || !entityId) { + return res.status(400).json({ + success: false, + message: 'entityType and entityId are required' + }); + } + + const totalLogs = await AuditLog.count({ + where: { + entityType: entityType as string, + entityId: entityId as string + } + }); + + const latestLog = await AuditLog.findOne({ + where: { + entityType: entityType as string, + entityId: entityId as string + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'fullName'] + }], + order: [['createdAt', 'DESC']] + }); + + res.json({ + success: true, + data: { + totalEntries: totalLogs, + lastActivity: latestLog ? { + action: (latestLog as any).action, + description: ACTION_DESCRIPTIONS[(latestLog as any).action] || (latestLog as any).action, + user: (latestLog as any).user?.fullName || 'System', + timestamp: (latestLog as any).createdAt + } : null + } + }); + } catch (error) { + console.error('Get audit summary error:', error); + res.status(500).json({ success: false, message: 'Error fetching audit summary' }); + } +}; + +/** + * Format changes between old and new data into a readable list + */ +function formatChanges(oldData: any, newData: any): string[] { + const changes: string[] = []; + + if (!oldData && newData) { + // New record created + if (typeof newData === 'object') { + Object.entries(newData).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + changes.push(`${formatFieldName(key)}: ${value}`); + } + }); + } + return changes; + } + + if (oldData && newData && typeof oldData === 'object' && typeof newData === 'object') { + // Compare old vs new + const allKeys = new Set([...Object.keys(oldData), ...Object.keys(newData)]); + allKeys.forEach(key => { + const oldVal = oldData[key]; + const newVal = newData[key]; + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + if (oldVal !== undefined && newVal !== undefined) { + changes.push(`${formatFieldName(key)}: "${oldVal}" → "${newVal}"`); + } else if (newVal !== undefined) { + changes.push(`${formatFieldName(key)} set to "${newVal}"`); + } + } + }); + } + + return changes; +} + +/** + * Convert camelCase field names to human-readable format + */ +function formatFieldName(field: string): string { + return field + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); +} diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..95a060b --- /dev/null +++ b/src/modules/audit/audit.routes.ts @@ -0,0 +1,15 @@ +import express from 'express'; +const router = express.Router(); +import * as auditController from './audit.controller.js'; +import { authenticate } from '../../common/middleware/auth.js'; + +// All audit routes require authentication +router.use(authenticate as any); + +// GET /api/audit/logs?entityType=application&entityId=&page=1&limit=50 +router.get('/logs', auditController.getAuditLogs); + +// GET /api/audit/summary?entityType=application&entityId= +router.get('/summary', auditController.getAuditSummary); + +export default router; diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index 696460e..6930d9d 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -1,14 +1,27 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { Worknote, User, WorkNoteTag, WorkNoteAttachment, Document, DocumentVersion, RequestParticipant, Application } = db; +const { Worknote, User, WorkNoteTag, WorkNoteAttachment, Document, DocumentVersion, RequestParticipant, Application, AuditLog } = db; import { AuthRequest } from '../../types/express.types.js'; +import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import * as EmailService from '../../common/utils/email.service.js'; +import { getIO } from '../../common/utils/socket.js'; +import * as NotificationService from '../../common/utils/notification.service.js'; +import logger from '../../common/utils/logger.js'; // --- Worknotes --- export const addWorknote = async (req: AuthRequest, res: Response) => { try { - const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body; // tags: ['Urgent'], attachmentDocIds: [uuid] + const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body; + logger.info(`Adding worknote for ${requestType} ${requestId}. Body:`, { noteText, tags, attachmentDocIds }); + + // Debug: Log participants + const participants = await db.RequestParticipant.findAll({ + where: { requestId, requestType }, + include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }] + }); + const simplifiedParticipants = participants.map((p: any) => ({ id: p.user?.id, name: p.user?.fullName })); + logger.info(`Participants for ${requestId}:`, simplifiedParticipants); const worknote = await Worknote.create({ requestId, @@ -21,13 +34,13 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { if (tags && tags.length > 0) { for (const tag of tags) { - await WorkNoteTag.create({ workNoteId: worknote.id, tag }); + await WorkNoteTag.create({ noteId: worknote.id, tagName: tag }); } } if (attachmentDocIds && attachmentDocIds.length > 0) { for (const docId of attachmentDocIds) { - await WorkNoteAttachment.create({ workNoteId: worknote.id, documentId: docId }); + await WorkNoteAttachment.create({ noteId: worknote.id, documentId: docId }); } } @@ -46,7 +59,79 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { }); } - res.status(201).json({ success: true, message: 'Worknote added', data: worknote }); + // Reload with associations for the response and socket emission + const fullWorknote = await Worknote.findByPk(worknote.id, { + include: [ + { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, + { model: WorkNoteTag, as: 'tags' }, + { model: Document, as: 'attachments' } + ] + }); + + // --- Real-time & Notifications --- + try { + const io = getIO(); + io.to(requestId).emit('new_worknote', fullWorknote); + + // Handle Mentions/Notifications + const notifiedUserIds = new Set(); + + // 1. Check structured mentions in noteText + logger.info(`Checking worknote for mentions. Text: "${noteText}"`); + if (noteText && typeof noteText === 'string' && noteText.includes('@')) { + const mentionRegex = /@\[([^\]]+)\]\(user:([^\)]+)\)/g; + let match; + while ((match = mentionRegex.exec(noteText)) !== null) { + const userId = match[2]; + if (userId && userId !== req.user?.id) { + notifiedUserIds.add(userId); + } + } + } + + // 2. Check tags (fallback/robustness) + if (tags && Array.isArray(tags)) { + tags.forEach((tag: any) => { + // If tag is a UUID-like string and not the current user + if (typeof tag === 'string' && tag.length > 20 && tag !== req.user?.id) { + notifiedUserIds.add(tag); + } + }); + } + + // Send Notifications + for (const userId of notifiedUserIds) { + logger.info(`Sending notification to user ID: ${userId}`); + try { + await NotificationService.sendNotification({ + userId, + title: 'New Mention', + message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`, + type: 'info', + link: `/applications/${requestId}?tab=worknotes` + }); + } catch (notifyErr) { + logger.warn(`Failed to send notification to ${userId}:`, notifyErr); + } + } + + if (notifiedUserIds.size === 0 && noteText && noteText.includes('@')) { + logger.warn('Worknote contains "@" but no mentions were successfully identified.'); + } + } catch (err) { + console.warn('Real-time notification failed:', err); + } + + // Audit log for worknote + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.WORKNOTE_ADDED, + entityType: 'application', + entityId: requestId, + newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) } + }); + + res.status(201).json({ success: true, message: 'Worknote added', data: fullWorknote }); } catch (error) { console.error('Add worknote error:', error); res.status(500).json({ success: false, message: 'Error adding worknote' }); @@ -62,7 +147,7 @@ export const getWorknotes = async (req: AuthRequest, res: Response) => { include: [ { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, - { model: WorkNoteAttachment, as: 'attachments', include: ['document'] } + { model: Document, as: 'attachments' } ], order: [['createdAt', 'DESC']] }); @@ -73,6 +158,63 @@ export const getWorknotes = async (req: AuthRequest, res: Response) => { } }; +export const uploadWorknoteAttachment = async (req: any, res: Response) => { + try { + const file = req.file; + const { requestId, requestType } = req.body; + + if (!file) { + return res.status(400).json({ success: false, message: 'No file uploaded' }); + } + + const document = await Document.create({ + requestId: requestId || null, + requestType: requestType || null, + documentType: 'Worknote Attachment', + fileName: file.originalname, + filePath: file.path, + mimeType: file.mimetype, + fileSize: file.size, + uploadedBy: req.user?.id, + status: 'active' + }); + + // Create initial version + await DocumentVersion.create({ + documentId: document.id, + versionNumber: 1, + filePath: file.path, + uploadedBy: req.user?.id, + changeReason: 'Initial Upload' + }); + + // Audit log for attachment upload + if (requestId) { + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED, + entityType: requestType || 'application', + entityId: requestId, + newData: { fileName: file.originalname, mimeType: file.mimetype } + }); + } + + res.status(201).json({ + success: true, + message: 'Attachment uploaded', + data: { + id: document.id, + fileName: document.fileName, + filePath: document.filePath, + mimeType: document.mimeType + } + }); + } catch (error) { + console.error('Upload worknote attachment error:', error); + res.status(500).json({ success: false, message: 'Error uploading attachment' }); + } +}; + // --- Documents --- export const uploadDocument = async (req: AuthRequest, res: Response) => { @@ -98,6 +240,15 @@ export const uploadDocument = async (req: AuthRequest, res: Response) => { changeReason: 'Initial Upload' }); + // Audit log for document upload + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.DOCUMENT_UPLOADED, + entityType: 'application', + entityId: applicationId, + newData: { docType, fileName } + }); + res.status(201).json({ success: true, message: 'Document uploaded', data: document }); } catch (error) { console.error('Upload document error:', error); @@ -163,6 +314,15 @@ export const addParticipant = async (req: AuthRequest, res: Response) => { ); } + // Audit log for participant added + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.PARTICIPANT_ADDED, + entityType: requestType || 'application', + entityId: requestId, + newData: { addedUserId: userId, participantType: participantType || 'contributor', userName: user?.fullName } + }); + res.status(201).json({ success: true, message: 'Participant added', data: participant }); } catch (error) { console.error('Add participant error:', error); @@ -173,7 +333,20 @@ export const addParticipant = async (req: AuthRequest, res: Response) => { export const removeParticipant = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; + const participant = await RequestParticipant.findByPk(id); await RequestParticipant.destroy({ where: { id } }); + + // Audit log for participant removed + if (participant) { + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, + entityType: (participant as any).requestType || 'application', + entityId: (participant as any).requestId, + newData: { removedUserId: (participant as any).userId } + }); + } + res.json({ success: true, message: 'Participant removed' }); } catch (error) { res.status(500).json({ success: false, message: 'Error removing participant' }); diff --git a/src/modules/collaboration/collaboration.routes.ts b/src/modules/collaboration/collaboration.routes.ts index f6389e1..45903d6 100644 --- a/src/modules/collaboration/collaboration.routes.ts +++ b/src/modules/collaboration/collaboration.routes.ts @@ -2,12 +2,14 @@ import express from 'express'; const router = express.Router(); import * as collaborationController from './collaboration.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; +import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js'; router.use(authenticate as any); // Worknotes router.get('/worknotes', collaborationController.getWorknotes); router.post('/worknotes', collaborationController.addWorknote); +router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment); // Participants router.post('/participants', collaborationController.addParticipant); diff --git a/src/modules/communication/communication.controller.ts b/src/modules/communication/communication.controller.ts index 6713be0..03743c6 100644 --- a/src/modules/communication/communication.controller.ts +++ b/src/modules/communication/communication.controller.ts @@ -35,3 +35,52 @@ export const getNotifications = async (req: AuthRequest, res: Response) => { res.status(500).json({ success: false, message: 'Error fetching notifications' }); } }; + +export const updatePushSubscription = async (req: AuthRequest, res: Response) => { + try { + const { subscription } = req.body; + const userId = req.user?.id; + + if (!subscription || !subscription.endpoint) { + return res.status(400).json({ success: false, message: 'Invalid subscription data' }); + } + + const { endpoint, keys } = subscription; + + await db.PushSubscription.upsert({ + userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + userAgent: req.get('user-agent') + }); + + res.json({ success: true, message: 'Push subscription updated' }); + } catch (error) { + console.error('Push subscription error:', error); + res.status(500).json({ success: false, message: 'Error updating push subscription' }); + } +}; + +export const markAsRead = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + await Notification.update({ isRead: true }, { + where: { id, userId: req.user?.id } + }); + res.json({ success: true, message: 'Notification marked as read' }); + } catch (error) { + res.status(500).json({ success: false, message: 'Error updating notification' }); + } +}; + +export const markAllAsRead = async (req: AuthRequest, res: Response) => { + try { + await Notification.update({ isRead: true }, { + where: { userId: req.user?.id, isRead: false } + }); + res.json({ success: true, message: 'All notifications marked as read' }); + } catch (error) { + res.status(500).json({ success: false, message: 'Error updating notifications' }); + } +}; diff --git a/src/modules/communication/communication.routes.ts b/src/modules/communication/communication.routes.ts index 1c5c8dc..5b2d425 100644 --- a/src/modules/communication/communication.routes.ts +++ b/src/modules/communication/communication.routes.ts @@ -13,5 +13,8 @@ router.post('/templates', checkRole([ROLES.SUPER_ADMIN]) as any, commController. // Notifications router.get('/notifications', commController.getNotifications); +router.patch('/notifications/:id/read', commController.markAsRead); +router.patch('/notifications/read-all', commController.markAllAsRead); +router.post('/notifications/subscribe', commController.updatePushSubscription); export default router; diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index cb9c80a..454edbf 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -477,7 +477,7 @@ export const getAreaManagers = async (req: Request, res: Response) => { export const updateArea = async (req: Request, res: Response) => { try { const { id } = req.params; - const { areaName, city, pincode, isActive, managerId } = req.body; + const { areaName, city, pincode, isActive, managerId, districtId, activeFrom, activeTo } = req.body; const area = await Area.findByPk(id); if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); @@ -486,8 +486,38 @@ export const updateArea = async (req: Request, res: Response) => { if (city) updates.city = city; if (pincode) updates.pincode = pincode; if (isActive !== undefined) updates.isActive = isActive; + if (activeFrom !== undefined) updates.activeFrom = activeFrom || null; + if (activeTo !== undefined) updates.activeTo = activeTo || null; if (managerId !== undefined) updates.managerId = managerId; // Legacy support + // If district is changed, update the entire hierarchy (State, Zone, Region) + if (districtId && districtId !== area.districtId) { + updates.districtId = districtId; + + const district = await District.findByPk(districtId, { + include: [{ + model: State, + as: 'state', + include: [ + { model: Zone, as: 'zone' }, + { model: Region, as: 'region' } + ] + }] + }); + + if (district) { + updates.stateId = district.stateId; + if (district.state) { + if (district.state.zone) { + updates.zoneId = district.state.zone.id; + } + if (district.state.region) { + updates.regionId = district.state.region.id; + } + } + } + } + await area.update(updates); // Handle AreaManager Table Update @@ -533,7 +563,7 @@ export const updateArea = async (req: Request, res: Response) => { } } - res.json({ success: true, message: 'Area updated' }); + res.json({ success: true, message: 'Area updated successfully' }); } catch (error) { console.error('Update area error:', error); res.status(500).json({ success: false, message: 'Error updating area' }); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 4d7d7d1..d06dd69 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -193,7 +193,7 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => { { model: db.RequestParticipant, as: 'participants', - include: [{ model: db.User, as: 'user', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }] + include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] } ] }); diff --git a/src/scripts/drop_worknote_constraint.ts b/src/scripts/drop_worknote_constraint.ts new file mode 100644 index 0000000..0dacf9a --- /dev/null +++ b/src/scripts/drop_worknote_constraint.ts @@ -0,0 +1,15 @@ +import { sequelize } from '../database/models/index.js'; + +async function dropConstraint() { + try { + console.log('Dropping worknotes_requestId_fkey constraint...'); + await sequelize.query('ALTER TABLE "worknotes" DROP CONSTRAINT IF EXISTS "worknotes_requestId_fkey"'); + console.log('Constraint dropped successfully (if it existed).'); + process.exit(0); + } catch (error) { + console.error('Error dropping constraint:', error); + process.exit(1); + } +} + +dropConstraint(); diff --git a/src/server.ts b/src/server.ts index fb84302..219fc51 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,8 @@ import compression from 'compression'; import rateLimit from 'express-rate-limit'; import path from 'path'; import { fileURLToPath } from 'url'; +import { createServer } from 'http'; +import { initSocket } from './common/utils/socket.js'; // Import database import db from './database/models/index.js'; @@ -31,6 +33,7 @@ import eorRoutes from './modules/eor/eor.routes.js'; import dealerRoutes from './modules/dealer/dealer.routes.js'; import slaRoutes from './modules/sla/sla.routes.js'; import communicationRoutes from './modules/communication/communication.routes.js'; +import auditRoutes from './modules/audit/audit.routes.js'; import questionnaireRoutes from './modules/onboarding/questionnaire.routes.js'; import prospectiveLoginRoutes from './modules/prospective-login/prospective-login.routes.js'; @@ -43,9 +46,22 @@ const __dirname = path.dirname(__filename); // Initialize Express app const app = express(); +const httpServer = createServer(app); + +// Initialize Socket.io +initSocket(httpServer); // Security middleware -app.use(helmet()); +app.use(helmet({ + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "frame-ancestors": ["'self'", process.env.FRONTEND_URL || 'http://localhost:5173'], + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: "cross-origin" } +})); app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173', credentials: true @@ -106,6 +122,7 @@ app.use('/api/eor', eorRoutes); app.use('/api/dealer', dealerRoutes); app.use('/api/sla', slaRoutes); app.use('/api/communication', communicationRoutes); +app.use('/api/audit', auditRoutes); app.use('/api/questionnaire', questionnaireRoutes); app.use('/api/prospective-login', prospectiveLoginRoutes); @@ -153,10 +170,11 @@ const startServer = async () => { } // Start server - app.listen(PORT, () => { + httpServer.listen(PORT, () => { logger.info(`🚀 Server running on port ${PORT}`); logger.info(`📍 Environment: ${process.env.NODE_ENV}`); logger.info(`🔗 API Base URL: http://localhost:${PORT}/api`); + logger.info(`🔌 Socket.io initialized`); }); } catch (error) { logger.error('Unable to start server:', error);