enhanced worknote service and create service for worknote live chat and inapp notificatio, also addd api for audit logs

This commit is contained in:
laxmanhalaki 2026-02-23 19:11:09 +05:30
parent 113e87b66d
commit 6b68364785
24 changed files with 1246 additions and 18 deletions

353
package-lock.json generated
View File

@ -24,7 +24,9 @@
"pg": "^8.18.0", "pg": "^8.18.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"socket.io": "^4.8.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"web-push": "^3.6.7",
"winston": "^3.19.0" "winston": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {
@ -41,6 +43,7 @@
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@types/web-push": "^3.6.4",
"jest": "^30.2.0", "jest": "^30.2.0",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"supertest": "^7.2.2", "supertest": "^7.2.2",
@ -3018,6 +3021,12 @@
"text-hex": "1.0.x" "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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@ -3152,7 +3161,6 @@
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -3388,6 +3396,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "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": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -3745,6 +3763,15 @@
"node": ">=0.4.0" "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": { "node_modules/ansi-escapes": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -3834,6 +3861,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -3953,6 +3992,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.16", "version": "2.9.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", "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" "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@ -4838,6 +4892,80 @@
"node": ">= 0.8" "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": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5666,6 +5794,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -5686,6 +5823,42 @@
"url": "https://opencollective.com/express" "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": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -7015,6 +7188,12 @@
"node": ">=6" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8311,6 +8490,138 @@
"node": ">=8" "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": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -9078,6 +9389,25 @@
"makeerror": "1.0.12" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "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": "^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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -39,7 +39,9 @@
"pg": "^8.18.0", "pg": "^8.18.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"socket.io": "^4.8.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"web-push": "^3.6.7",
"winston": "^3.19.0" "winston": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {
@ -56,6 +58,7 @@
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@types/web-push": "^3.6.4",
"jest": "^30.2.0", "jest": "^30.2.0",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"supertest": "^7.2.2", "supertest": "^7.2.2",

View File

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

View File

@ -189,15 +189,83 @@ export const FNF_STATUS = {
// Audit Actions // Audit Actions
export const AUDIT_ACTIONS = { export const AUDIT_ACTIONS = {
// General CRUD
CREATED: 'CREATED', CREATED: 'CREATED',
UPDATED: 'UPDATED', UPDATED: 'UPDATED',
APPROVED: 'APPROVED', APPROVED: 'APPROVED',
REJECTED: 'REJECTED', REJECTED: 'REJECTED',
DELETED: 'DELETED', DELETED: 'DELETED',
// Auth & User Actions
LOGIN: 'LOGIN', LOGIN: 'LOGIN',
LOGOUT: 'LOGOUT',
REGISTERED: 'REGISTERED',
PASSWORD_CHANGED: 'PASSWORD_CHANGED',
PROFILE_UPDATED: 'PROFILE_UPDATED',
// Application Lifecycle
STAGE_CHANGED: 'STAGE_CHANGED', STAGE_CHANGED: 'STAGE_CHANGED',
SHORTLISTED: 'SHORTLISTED',
DISQUALIFIED: 'DISQUALIFIED',
QUESTIONNAIRE_SUBMITTED: 'QUESTIONNAIRE_SUBMITTED',
QUESTIONNAIRE_LINK_SENT: 'QUESTIONNAIRE_LINK_SENT',
// Documents & Collaboration
DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED', 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; } as const;
// Document Types // Document Types

View File

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

View File

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

View File

@ -246,6 +246,12 @@ export default (sequelize: Sequelize) => {
}); });
Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' }); 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, { Application.hasMany(models.RequestParticipant, {
foreignKey: 'requestId', foreignKey: 'requestId',
as: 'participants', as: 'participants',

View File

@ -96,7 +96,8 @@ export default (sequelize: Sequelize) => {
ConstitutionalChange.hasMany(models.Worknote, { ConstitutionalChange.hasMany(models.Worknote, {
foreignKey: 'requestId', foreignKey: 'requestId',
as: 'worknotes', as: 'worknotes',
scope: { requestType: 'constitutional' } scope: { requestType: 'constitutional' },
constraints: false
}); });
}; };

View File

@ -102,7 +102,12 @@ export default (sequelize: Sequelize) => {
Document.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' }); Document.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' });
Document.hasMany(models.DocumentVersion, { foreignKey: 'documentId', as: 'versions' }); 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; return Document;

View File

@ -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>, PushSubscriptionAttributes { }
export default (sequelize: Sequelize) => {
const PushSubscription = sequelize.define<PushSubscriptionInstance>('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;
};

View File

@ -111,7 +111,8 @@ export default (sequelize: Sequelize) => {
RelocationRequest.hasMany(models.Worknote, { RelocationRequest.hasMany(models.Worknote, {
foreignKey: 'requestId', foreignKey: 'requestId',
as: 'worknotes', as: 'worknotes',
scope: { requestType: 'relocation' } scope: { requestType: 'relocation' },
constraints: false
}); });
}; };

View File

@ -124,7 +124,8 @@ export default (sequelize: Sequelize) => {
as: 'worknotes', as: 'worknotes',
scope: { scope: {
requestType: 'resignation' requestType: 'resignation'
} },
constraints: false
}); });
}; };

View File

@ -71,7 +71,12 @@ export default (sequelize: Sequelize) => {
Worknote.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' }); Worknote.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' });
Worknote.hasMany(models.WorkNoteTag, { foreignKey: 'noteId', as: 'tags' }); 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; return Worknote;

View File

@ -82,6 +82,7 @@ import createExitFeedback from './ExitFeedback.js';
// Batch 7: Notifications, Logs & Templates // Batch 7: Notifications, Logs & Templates
import createEmailTemplate from './EmailTemplate.js'; import createEmailTemplate from './EmailTemplate.js';
import createPushSubscription from './PushSubscription.js';
// Batch 8: SLA & TAT Tracking // Batch 8: SLA & TAT Tracking
import createSLATracking from './SLATracking.js'; import createSLATracking from './SLATracking.js';
@ -188,6 +189,7 @@ db.ExitFeedback = createExitFeedback(sequelize);
// Batch 7: Notifications, Logs & Templates // Batch 7: Notifications, Logs & Templates
db.EmailTemplate = createEmailTemplate(sequelize); db.EmailTemplate = createEmailTemplate(sequelize);
db.PushSubscription = createPushSubscription(sequelize);
// Batch 8: SLA & TAT Tracking // Batch 8: SLA & TAT Tracking
db.SLATracking = createSLATracking(sequelize); db.SLATracking = createSLATracking(sequelize);

View File

@ -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<string, string> = {
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();
}

View File

@ -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=<uuid>&page=1&limit=50
router.get('/logs', auditController.getAuditLogs);
// GET /api/audit/summary?entityType=application&entityId=<uuid>
router.get('/summary', auditController.getAuditSummary);
export default router;

View File

@ -1,14 +1,27 @@
import { Response } from 'express'; import { Response } from 'express';
import db from '../../database/models/index.js'; 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 { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.js';
import * as EmailService from '../../common/utils/email.service.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 --- // --- Worknotes ---
export const addWorknote = async (req: AuthRequest, res: Response) => { export const addWorknote = async (req: AuthRequest, res: Response) => {
try { 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({ const worknote = await Worknote.create({
requestId, requestId,
@ -21,13 +34,13 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
for (const tag of tags) { 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) { if (attachmentDocIds && attachmentDocIds.length > 0) {
for (const docId of attachmentDocIds) { 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<string>();
// 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) { } catch (error) {
console.error('Add worknote error:', error); console.error('Add worknote error:', error);
res.status(500).json({ success: false, message: 'Error adding worknote' }); res.status(500).json({ success: false, message: 'Error adding worknote' });
@ -62,7 +147,7 @@ export const getWorknotes = async (req: AuthRequest, res: Response) => {
include: [ include: [
{ model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] },
{ model: WorkNoteTag, as: 'tags' }, { model: WorkNoteTag, as: 'tags' },
{ model: WorkNoteAttachment, as: 'attachments', include: ['document'] } { model: Document, as: 'attachments' }
], ],
order: [['createdAt', 'DESC']] 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 --- // --- Documents ---
export const uploadDocument = async (req: AuthRequest, res: Response) => { export const uploadDocument = async (req: AuthRequest, res: Response) => {
@ -98,6 +240,15 @@ export const uploadDocument = async (req: AuthRequest, res: Response) => {
changeReason: 'Initial Upload' 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 }); res.status(201).json({ success: true, message: 'Document uploaded', data: document });
} catch (error) { } catch (error) {
console.error('Upload document error:', 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 }); res.status(201).json({ success: true, message: 'Participant added', data: participant });
} catch (error) { } catch (error) {
console.error('Add participant error:', 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) => { export const removeParticipant = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const participant = await RequestParticipant.findByPk(id);
await RequestParticipant.destroy({ where: { 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' }); res.json({ success: true, message: 'Participant removed' });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, message: 'Error removing participant' }); res.status(500).json({ success: false, message: 'Error removing participant' });

View File

@ -2,12 +2,14 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
import * as collaborationController from './collaboration.controller.js'; import * as collaborationController from './collaboration.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js';
router.use(authenticate as any); router.use(authenticate as any);
// Worknotes // Worknotes
router.get('/worknotes', collaborationController.getWorknotes); router.get('/worknotes', collaborationController.getWorknotes);
router.post('/worknotes', collaborationController.addWorknote); router.post('/worknotes', collaborationController.addWorknote);
router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
// Participants // Participants
router.post('/participants', collaborationController.addParticipant); router.post('/participants', collaborationController.addParticipant);

View File

@ -35,3 +35,52 @@ export const getNotifications = async (req: AuthRequest, res: Response) => {
res.status(500).json({ success: false, message: 'Error fetching notifications' }); 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' });
}
};

View File

@ -13,5 +13,8 @@ router.post('/templates', checkRole([ROLES.SUPER_ADMIN]) as any, commController.
// Notifications // Notifications
router.get('/notifications', commController.getNotifications); 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; export default router;

View File

@ -477,7 +477,7 @@ export const getAreaManagers = async (req: Request, res: Response) => {
export const updateArea = async (req: Request, res: Response) => { export const updateArea = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; 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); const area = await Area.findByPk(id);
if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); 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 (city) updates.city = city;
if (pincode) updates.pincode = pincode; if (pincode) updates.pincode = pincode;
if (isActive !== undefined) updates.isActive = isActive; 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 (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); await area.update(updates);
// Handle AreaManager Table Update // 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) { } catch (error) {
console.error('Update area error:', error); console.error('Update area error:', error);
res.status(500).json({ success: false, message: 'Error updating area' }); res.status(500).json({ success: false, message: 'Error updating area' });

View File

@ -193,7 +193,7 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => {
{ {
model: db.RequestParticipant, model: db.RequestParticipant,
as: 'participants', 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']] }]
} }
] ]
}); });

View File

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

View File

@ -6,6 +6,8 @@ import compression from 'compression';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createServer } from 'http';
import { initSocket } from './common/utils/socket.js';
// Import database // Import database
import db from './database/models/index.js'; 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 dealerRoutes from './modules/dealer/dealer.routes.js';
import slaRoutes from './modules/sla/sla.routes.js'; import slaRoutes from './modules/sla/sla.routes.js';
import communicationRoutes from './modules/communication/communication.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 questionnaireRoutes from './modules/onboarding/questionnaire.routes.js';
import prospectiveLoginRoutes from './modules/prospective-login/prospective-login.routes.js'; import prospectiveLoginRoutes from './modules/prospective-login/prospective-login.routes.js';
@ -43,9 +46,22 @@ const __dirname = path.dirname(__filename);
// Initialize Express app // Initialize Express app
const app = express(); const app = express();
const httpServer = createServer(app);
// Initialize Socket.io
initSocket(httpServer);
// Security middleware // 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({ app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173', origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true credentials: true
@ -106,6 +122,7 @@ app.use('/api/eor', eorRoutes);
app.use('/api/dealer', dealerRoutes); app.use('/api/dealer', dealerRoutes);
app.use('/api/sla', slaRoutes); app.use('/api/sla', slaRoutes);
app.use('/api/communication', communicationRoutes); app.use('/api/communication', communicationRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/questionnaire', questionnaireRoutes); app.use('/api/questionnaire', questionnaireRoutes);
app.use('/api/prospective-login', prospectiveLoginRoutes); app.use('/api/prospective-login', prospectiveLoginRoutes);
@ -153,10 +170,11 @@ const startServer = async () => {
} }
// Start server // Start server
app.listen(PORT, () => { httpServer.listen(PORT, () => {
logger.info(`🚀 Server running on port ${PORT}`); logger.info(`🚀 Server running on port ${PORT}`);
logger.info(`📍 Environment: ${process.env.NODE_ENV}`); logger.info(`📍 Environment: ${process.env.NODE_ENV}`);
logger.info(`🔗 API Base URL: http://localhost:${PORT}/api`); logger.info(`🔗 API Base URL: http://localhost:${PORT}/api`);
logger.info(`🔌 Socket.io initialized`);
}); });
} catch (error) { } catch (error) {
logger.error('Unable to start server:', error); logger.error('Unable to start server:', error);