Commit 64deba4d by krds-arun

implemented azure ad api

parent 8a959f9b
...@@ -13,12 +13,17 @@ ...@@ -13,12 +13,17 @@
"@fastify/compress": "^8.3.1", "@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"bcrypt": "^6.0.0",
"fastify": "^5.6.1", "fastify": "^5.6.1",
"ioredis": "^5.8.1", "ioredis": "^5.8.1",
"pg": "^8.16.3", "jose": "^6.2.3",
"pg": "^8.21.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.8",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
......
...@@ -20,19 +20,34 @@ importers: ...@@ -20,19 +20,34 @@ importers:
'@fastify/multipart': '@fastify/multipart':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
bcrypt:
specifier: ^6.0.0
version: 6.0.0
fastify: fastify:
specifier: ^5.6.1 specifier: ^5.6.1
version: 5.8.5 version: 5.8.5
ioredis: ioredis:
specifier: ^5.8.1 specifier: ^5.8.1
version: 5.10.1 version: 5.10.1
jose:
specifier: ^6.2.3
version: 6.2.3
pg: pg:
specifier: ^8.16.3 specifier: ^8.21.0
version: 8.20.0 version: 8.21.0
pg-hstore:
specifier: ^2.3.4
version: 2.3.4
sequelize:
specifier: ^6.37.8
version: 6.37.8(pg-hstore@2.3.4)(pg@8.21.0)
zod: zod:
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.4.3 version: 4.4.3
devDependencies: devDependencies:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/js-yaml': '@types/js-yaml':
specifier: ^4.0.9 specifier: ^4.0.9
version: 4.0.9 version: 4.0.9
...@@ -310,15 +325,27 @@ packages: ...@@ -310,15 +325,27 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/js-yaml@4.0.9': '@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.12.2': '@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/pg@8.20.0': '@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@typespec/ts-http-runtime@0.3.5': '@typespec/ts-http-runtime@0.3.5':
resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==} resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
...@@ -358,6 +385,10 @@ packages: ...@@ -358,6 +385,10 @@ packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
...@@ -392,6 +423,10 @@ packages: ...@@ -392,6 +423,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
dottie@2.0.7:
resolution: {integrity: sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
duplexify@3.7.1: duplexify@3.7.1:
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
...@@ -468,6 +503,10 @@ packages: ...@@ -468,6 +503,10 @@ packages:
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inflection@1.13.4:
resolution: {integrity: sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==}
engines: {'0': node >= 0.4.0}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
...@@ -482,6 +521,9 @@ packages: ...@@ -482,6 +521,9 @@ packages:
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
...@@ -501,6 +543,9 @@ packages: ...@@ -501,6 +543,9 @@ packages:
lodash.isarguments@3.1.0: lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
mime-db@1.54.0: mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
...@@ -509,9 +554,23 @@ packages: ...@@ -509,9 +554,23 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
node-addon-api@8.7.0:
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
on-exit-leak-free@2.1.2: on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
...@@ -526,30 +585,37 @@ packages: ...@@ -526,30 +585,37 @@ packages:
peek-stream@1.1.3: peek-stream@1.1.3:
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
pg-cloudflare@1.3.0: pg-cloudflare@1.4.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
pg-connection-string@2.12.0: pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-connection-string@2.13.0:
resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==}
pg-hstore@2.3.4:
resolution: {integrity: sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==}
engines: {node: '>= 0.8.x'}
pg-int8@1.0.1: pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
pg-pool@3.13.0: pg-pool@3.14.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies: peerDependencies:
pg: '>=8.0' pg: '>=8.0'
pg-protocol@1.13.0: pg-protocol@1.14.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==}
pg-types@2.2.0: pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'} engines: {node: '>=4'}
pg@8.20.0: pg@8.21.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==}
engines: {node: '>= 16.0.0'} engines: {node: '>= 16.0.0'}
peerDependencies: peerDependencies:
pg-native: '>=3.0.1' pg-native: '>=3.0.1'
...@@ -642,6 +708,9 @@ packages: ...@@ -642,6 +708,9 @@ packages:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'} engines: {node: '>=10'}
retry-as-promised@7.1.1:
resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==}
reusify@1.1.0: reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
...@@ -671,6 +740,43 @@ packages: ...@@ -671,6 +740,43 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
sequelize-pool@7.1.0:
resolution: {integrity: sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==}
engines: {node: '>= 10.0.0'}
sequelize@6.37.8:
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
engines: {node: '>=10.0.0'}
peerDependencies:
ibm_db: '*'
mariadb: '*'
mysql2: '*'
oracledb: '*'
pg: '*'
pg-hstore: '*'
snowflake-sdk: '*'
sqlite3: '*'
tedious: '*'
peerDependenciesMeta:
ibm_db:
optional: true
mariadb:
optional: true
mysql2:
optional: true
oracledb:
optional: true
pg:
optional: true
pg-hstore:
optional: true
snowflake-sdk:
optional: true
sqlite3:
optional: true
tedious:
optional: true
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
...@@ -707,6 +813,9 @@ packages: ...@@ -707,6 +813,9 @@ packages:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'} engines: {node: '>=12'}
toposort-class@1.0.1:
resolution: {integrity: sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
...@@ -720,12 +829,27 @@ packages: ...@@ -720,12 +829,27 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
wkx@0.5.0:
resolution: {integrity: sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
...@@ -988,8 +1112,18 @@ snapshots: ...@@ -988,8 +1112,18 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 24.12.2
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/js-yaml@4.0.9': {} '@types/js-yaml@4.0.9': {}
'@types/ms@2.1.0': {}
'@types/node@24.12.2': '@types/node@24.12.2':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
...@@ -997,9 +1131,11 @@ snapshots: ...@@ -997,9 +1131,11 @@ snapshots:
'@types/pg@8.20.0': '@types/pg@8.20.0':
dependencies: dependencies:
'@types/node': 24.12.2 '@types/node': 24.12.2
pg-protocol: 1.13.0 pg-protocol: 1.14.0
pg-types: 2.2.0 pg-types: 2.2.0
'@types/validator@13.15.10': {}
'@typespec/ts-http-runtime@0.3.5': '@typespec/ts-http-runtime@0.3.5':
dependencies: dependencies:
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
...@@ -1038,6 +1174,11 @@ snapshots: ...@@ -1038,6 +1174,11 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.7.0
node-gyp-build: 4.8.4
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@6.0.3: buffer@6.0.3:
...@@ -1059,6 +1200,8 @@ snapshots: ...@@ -1059,6 +1200,8 @@ snapshots:
dequal@2.0.3: {} dequal@2.0.3: {}
dottie@2.0.7: {}
duplexify@3.7.1: duplexify@3.7.1:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
...@@ -1193,6 +1336,8 @@ snapshots: ...@@ -1193,6 +1336,8 @@ snapshots:
ieee754@1.2.1: {} ieee754@1.2.1: {}
inflection@1.13.4: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ioredis@5.10.1: ioredis@5.10.1:
...@@ -1213,6 +1358,8 @@ snapshots: ...@@ -1213,6 +1358,8 @@ snapshots:
isarray@1.0.0: {} isarray@1.0.0: {}
jose@6.2.3: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
...@@ -1233,12 +1380,24 @@ snapshots: ...@@ -1233,12 +1380,24 @@ snapshots:
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
lodash@4.18.1: {}
mime-db@1.54.0: {} mime-db@1.54.0: {}
minipass@7.1.3: {} minipass@7.1.3: {}
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
ms@2.1.3: {} ms@2.1.3: {}
node-addon-api@8.7.0: {}
node-gyp-build@4.8.4: {}
on-exit-leak-free@2.1.2: {} on-exit-leak-free@2.1.2: {}
once@1.4.0: once@1.4.0:
...@@ -1253,18 +1412,24 @@ snapshots: ...@@ -1253,18 +1412,24 @@ snapshots:
duplexify: 3.7.1 duplexify: 3.7.1
through2: 2.0.5 through2: 2.0.5
pg-cloudflare@1.3.0: pg-cloudflare@1.4.0:
optional: true optional: true
pg-connection-string@2.12.0: {} pg-connection-string@2.12.0: {}
pg-connection-string@2.13.0: {}
pg-hstore@2.3.4:
dependencies:
underscore: 1.13.8
pg-int8@1.0.1: {} pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0): pg-pool@3.14.0(pg@8.21.0):
dependencies: dependencies:
pg: 8.20.0 pg: 8.21.0
pg-protocol@1.13.0: {} pg-protocol@1.14.0: {}
pg-types@2.2.0: pg-types@2.2.0:
dependencies: dependencies:
...@@ -1274,15 +1439,15 @@ snapshots: ...@@ -1274,15 +1439,15 @@ snapshots:
postgres-date: 1.0.7 postgres-date: 1.0.7
postgres-interval: 1.2.0 postgres-interval: 1.2.0
pg@8.20.0: pg@8.21.0:
dependencies: dependencies:
pg-connection-string: 2.12.0 pg-connection-string: 2.13.0
pg-pool: 3.13.0(pg@8.20.0) pg-pool: 3.14.0(pg@8.21.0)
pg-protocol: 1.13.0 pg-protocol: 1.14.0
pg-types: 2.2.0 pg-types: 2.2.0
pgpass: 1.0.5 pgpass: 1.0.5
optionalDependencies: optionalDependencies:
pg-cloudflare: 1.3.0 pg-cloudflare: 1.4.0
pgpass@1.0.5: pgpass@1.0.5:
dependencies: dependencies:
...@@ -1377,6 +1542,8 @@ snapshots: ...@@ -1377,6 +1542,8 @@ snapshots:
ret@0.5.0: {} ret@0.5.0: {}
retry-as-promised@7.1.1: {}
reusify@1.1.0: {} reusify@1.1.0: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
...@@ -1395,6 +1562,32 @@ snapshots: ...@@ -1395,6 +1562,32 @@ snapshots:
semver@7.7.4: {} semver@7.7.4: {}
sequelize-pool@7.1.0: {}
sequelize@6.37.8(pg-hstore@2.3.4)(pg@8.21.0):
dependencies:
'@types/debug': 4.1.13
'@types/validator': 13.15.10
debug: 4.4.3
dottie: 2.0.7
inflection: 1.13.4
lodash: 4.18.1
moment: 2.30.1
moment-timezone: 0.5.48
pg-connection-string: 2.12.0
retry-as-promised: 7.1.1
semver: 7.7.4
sequelize-pool: 7.1.0
toposort-class: 1.0.1
uuid: 8.3.2
validator: 13.15.35
wkx: 0.5.0
optionalDependencies:
pg: 8.21.0
pg-hstore: 2.3.4
transitivePeerDependencies:
- supports-color
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
sonic-boom@4.2.1: sonic-boom@4.2.1:
...@@ -1428,6 +1621,8 @@ snapshots: ...@@ -1428,6 +1621,8 @@ snapshots:
toad-cache@3.7.0: {} toad-cache@3.7.0: {}
toposort-class@1.0.1: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsx@4.21.0: tsx@4.21.0:
...@@ -1439,10 +1634,20 @@ snapshots: ...@@ -1439,10 +1634,20 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
underscore@1.13.8: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@8.3.2: {}
validator@13.15.35: {}
wkx@0.5.0:
dependencies:
'@types/node': 24.12.2
wrappy@1.0.2: {} wrappy@1.0.2: {}
xtend@4.0.2: {} xtend@4.0.2: {}
......
// Prisma 7 configuration. The CLI reads this to know where the schema lives
// and which adapter to use for `prisma db pull`, `prisma migrate`, etc.
// Runtime PrismaClient instantiation lives in src/db/prisma.ts and uses the
// same adapter pattern.
import "dotenv/config"; // optional; the fastify config already loads .env at runtime
import fs from "node:fs";
import path from "node:path";
import { defineConfig } from "prisma/config";
import { PrismaPg } from "@prisma/adapter-pg";
// Tiny .env loader so this file works standalone when `node:dotenv` isn't
// available (prisma CLI runs this file directly). Idempotent with the
// loader in src/config.ts.
(function ensureEnv() {
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) return;
const text = fs.readFileSync(envPath, "utf8");
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const k = trimmed.slice(0, eq).trim();
let v = trimmed.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
if (process.env[k] === undefined) process.env[k] = v;
}
})();
const connectionString = process.env["POSTGRES_URL"]
?? "postgres://postgres:password@localhost:5432/maf-gateway";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: connectionString,
},
migrations: {
adapter: () => Promise.resolve(new PrismaPg({ connectionString })),
},
});
...@@ -35,6 +35,14 @@ export const config = { ...@@ -35,6 +35,14 @@ export const config = {
schemaTtlSeconds: Number(process.env["CACHE_TTL_SCHEMA_SECONDS"] ?? 300), schemaTtlSeconds: Number(process.env["CACHE_TTL_SCHEMA_SECONDS"] ?? 300),
masterTtlSeconds: Number(process.env["CACHE_TTL_MASTER_SECONDS"] ?? 180), masterTtlSeconds: Number(process.env["CACHE_TTL_MASTER_SECONDS"] ?? 180),
auth: {
jwtSecret: required(
"NEXTAUTH_SECRET",
// Dev-only fallback — production must set NEXTAUTH_SECRET to a 32+ byte secret.
"dev-only-please-replace-with-32+-byte-random-secret",
),
},
azure: { azure: {
accountName: required("AZURE_ACCOUNT_NAME", ""), accountName: required("AZURE_ACCOUNT_NAME", ""),
accountKey: required("AZURE_ACCOUNT_KEY", ""), accountKey: required("AZURE_ACCOUNT_KEY", ""),
......
/**
* Sequelize models for the `auth.*` tables. Only the columns the API
* currently reads/writes are mapped; the rest stay accessible via raw
* `sequelize.query()` if some endpoint needs them later.
*/
import {
DataTypes,
Model,
type InferAttributes,
type InferCreationAttributes,
type CreationOptional,
type ForeignKey,
} from "sequelize";
import { sequelize } from "../sequelize.js";
// ─── User ────────────────────────────────────────────────────────────────────
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
declare id: CreationOptional<string>;
declare email: string;
declare username: string;
declare isActive: CreationOptional<boolean>;
declare isLocked: CreationOptional<boolean>;
declare isDeleted: CreationOptional<boolean>;
declare authProvider: CreationOptional<string | null>;
declare displayName: CreationOptional<string | null>;
declare firstName: CreationOptional<string | null>;
declare lastName: CreationOptional<string | null>;
declare avatarUrl: CreationOptional<string | null>;
declare locale: CreationOptional<string>;
declare timezone: CreationOptional<string>;
declare failedLoginCount: CreationOptional<number>;
declare lastFailedLoginAt: CreationOptional<Date | null>;
declare lockedUntil: CreationOptional<Date | null>;
declare lastLoginAt: CreationOptional<Date | null>;
declare emailVerifiedAt: CreationOptional<Date | null>;
declare mustChangePassword: CreationOptional<boolean>;
declare mfaEnabled: CreationOptional<boolean>;
declare externalId: CreationOptional<string | null>;
declare metadata: CreationOptional<Record<string, unknown>>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date | null>;
declare deletedAt: CreationOptional<Date | null>;
}
User.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
email: { type: DataTypes.TEXT, allowNull: false },
username: { type: DataTypes.TEXT, allowNull: false },
isActive: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, field: "is_active" },
isLocked: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "is_locked" },
isDeleted: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "is_deleted" },
authProvider: { type: DataTypes.TEXT, allowNull: true, field: "auth_provider" },
displayName: { type: DataTypes.TEXT, allowNull: true, field: "display_name" },
firstName: { type: DataTypes.TEXT, allowNull: true, field: "first_name" },
lastName: { type: DataTypes.TEXT, allowNull: true, field: "last_name" },
avatarUrl: { type: DataTypes.TEXT, allowNull: true, field: "avatar_url" },
locale: { type: DataTypes.TEXT, allowNull: false, defaultValue: "en-US" },
timezone: { type: DataTypes.TEXT, allowNull: false, defaultValue: "Asia/Dubai" },
failedLoginCount: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "failed_login_count" },
lastFailedLoginAt: { type: DataTypes.DATE, allowNull: true, field: "last_failed_login_at" },
lockedUntil: { type: DataTypes.DATE, allowNull: true, field: "locked_until" },
lastLoginAt: { type: DataTypes.DATE, allowNull: true, field: "last_login_at" },
emailVerifiedAt: { type: DataTypes.DATE, allowNull: true, field: "email_verified_at" },
mustChangePassword: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "must_change_password" },
mfaEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "mfa_enabled" },
externalId: { type: DataTypes.TEXT, allowNull: true, field: "external_id" },
metadata: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
updatedAt: { type: DataTypes.DATE, allowNull: true, field: "updated_at" },
deletedAt: { type: DataTypes.DATE, allowNull: true, field: "deleted_at" },
},
{
sequelize,
tableName: "users",
schema: "auth",
modelName: "User",
timestamps: false,
},
);
// ─── UserPassword (1-1 with User) ────────────────────────────────────────────
export class UserPassword extends Model<
InferAttributes<UserPassword>,
InferCreationAttributes<UserPassword>
> {
declare userId: ForeignKey<string>;
declare passwordHash: string;
declare passwordChangedAt: CreationOptional<Date | null>;
declare mustChange: CreationOptional<boolean>;
declare algorithm: CreationOptional<string>;
declare replacedAt: CreationOptional<Date | null>;
declare replacedReason: CreationOptional<string | null>;
}
UserPassword.init(
{
userId: { type: DataTypes.UUID, primaryKey: true, field: "user_id" },
passwordHash: { type: DataTypes.TEXT, allowNull: false, field: "password_hash" },
passwordChangedAt: { type: DataTypes.DATE, allowNull: true, field: "password_changed_at" },
mustChange: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "must_change" },
algorithm: { type: DataTypes.TEXT, allowNull: false, defaultValue: "bcrypt" },
replacedAt: { type: DataTypes.DATE, allowNull: true, field: "replaced_at" },
replacedReason: { type: DataTypes.TEXT, allowNull: true, field: "replaced_reason" },
},
{ sequelize, tableName: "user_passwords", schema: "auth", modelName: "UserPassword", timestamps: false },
);
// ─── Role + UserRole join ────────────────────────────────────────────────────
export class Role extends Model<InferAttributes<Role>, InferCreationAttributes<Role>> {
declare id: CreationOptional<string>;
declare name: string;
declare code: CreationOptional<string | null>;
declare description: CreationOptional<string | null>;
declare isSystem: CreationOptional<boolean>;
declare isDefault: CreationOptional<boolean>;
declare parentRoleId: CreationOptional<string | null>;
declare metadata: CreationOptional<Record<string, unknown>>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>;
}
Role.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
name: { type: DataTypes.TEXT, allowNull: false },
code: { type: DataTypes.TEXT, allowNull: true },
description: { type: DataTypes.TEXT, allowNull: true },
isSystem: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "is_system" },
isDefault: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, field: "is_default" },
parentRoleId: { type: DataTypes.UUID, allowNull: true, field: "parent_role_id" },
metadata: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
updatedAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at" },
deletedAt: { type: DataTypes.DATE, allowNull: true, field: "deleted_at" },
},
{ sequelize, tableName: "roles", schema: "auth", modelName: "Role", timestamps: false },
);
export class UserRole extends Model<InferAttributes<UserRole>, InferCreationAttributes<UserRole>> {
declare userId: ForeignKey<string>;
declare roleId: ForeignKey<string>;
declare assignedAt: CreationOptional<Date>;
}
UserRole.init(
{
userId: { type: DataTypes.UUID, primaryKey: true, field: "user_id" },
roleId: { type: DataTypes.UUID, primaryKey: true, field: "role_id" },
assignedAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "assigned_at" },
},
{ sequelize, tableName: "user_roles", schema: "auth", modelName: "UserRole", timestamps: false },
);
User.belongsToMany(Role, { through: UserRole, foreignKey: "user_id", otherKey: "role_id", as: "roles" });
Role.belongsToMany(User, { through: UserRole, foreignKey: "role_id", otherKey: "user_id", as: "users" });
User.hasOne(UserPassword, { foreignKey: "user_id", as: "password" });
UserPassword.belongsTo(User, { foreignKey: "user_id", as: "user" });
// ─── UserSsoAccount ──────────────────────────────────────────────────────────
export class UserSsoAccount extends Model<
InferAttributes<UserSsoAccount>,
InferCreationAttributes<UserSsoAccount>
> {
declare id: CreationOptional<string>;
declare userId: ForeignKey<string>;
declare provider: string;
declare providerUserId: string;
declare email: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
}
UserSsoAccount.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
userId: { type: DataTypes.UUID, allowNull: false, field: "user_id" },
provider: { type: DataTypes.TEXT, allowNull: false },
providerUserId: { type: DataTypes.TEXT, allowNull: false, field: "provider_user_id" },
email: { type: DataTypes.TEXT, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
},
{ sequelize, tableName: "user_sso_accounts", schema: "auth", modelName: "UserSsoAccount", timestamps: false },
);
UserSsoAccount.belongsTo(User, { foreignKey: "user_id", as: "user" });
User.hasMany(UserSsoAccount, { foreignKey: "user_id", as: "ssoAccounts" });
// ─── UserSession (optional - kept for revoke history / device list) ──────────
export class UserSession extends Model<
InferAttributes<UserSession>,
InferCreationAttributes<UserSession>
> {
declare id: CreationOptional<string>;
declare userId: ForeignKey<string>;
declare ipAddress: CreationOptional<string | null>;
declare userAgent: CreationOptional<string | null>;
declare expiresAt: Date;
declare createdAt: CreationOptional<Date>;
declare lastSeenAt: CreationOptional<Date | null>;
declare revokedAt: CreationOptional<Date | null>;
declare revokeReason: CreationOptional<string | null>;
declare deviceId: CreationOptional<string | null>;
declare deviceName: CreationOptional<string | null>;
}
UserSession.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
userId: { type: DataTypes.UUID, allowNull: false, field: "user_id" },
ipAddress: { type: DataTypes.INET, allowNull: true, field: "ip_address" },
userAgent: { type: DataTypes.TEXT, allowNull: true, field: "user_agent" },
expiresAt: { type: DataTypes.DATE, allowNull: false, field: "expires_at" },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
lastSeenAt: { type: DataTypes.DATE, allowNull: true, field: "last_seen_at" },
revokedAt: { type: DataTypes.DATE, allowNull: true, field: "revoked_at" },
revokeReason: { type: DataTypes.TEXT, allowNull: true, field: "revoke_reason" },
deviceId: { type: DataTypes.TEXT, allowNull: true, field: "device_id" },
deviceName: { type: DataTypes.TEXT, allowNull: true, field: "device_name" },
},
{ sequelize, tableName: "user_sessions", schema: "auth", modelName: "UserSession", timestamps: false },
);
// ─── Audit log (write-only from the app) ─────────────────────────────────────
export class SecurityEvent extends Model<
InferAttributes<SecurityEvent>,
InferCreationAttributes<SecurityEvent>
> {
declare id: CreationOptional<string>;
declare userId: ForeignKey<string>;
declare eventType: string;
declare severity: CreationOptional<string | null>;
declare details: CreationOptional<Record<string, unknown> | null>;
declare createdAt: CreationOptional<Date>;
}
SecurityEvent.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
userId: { type: DataTypes.UUID, allowNull: false, field: "user_id" },
eventType: { type: DataTypes.TEXT, allowNull: false, field: "event_type" },
severity: { type: DataTypes.TEXT, allowNull: true },
details: { type: DataTypes.JSONB, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
},
{ sequelize, tableName: "security_events", schema: "auth", modelName: "SecurityEvent", timestamps: false },
);
export class UserActivityLog extends Model<
InferAttributes<UserActivityLog>,
InferCreationAttributes<UserActivityLog>
> {
declare id: CreationOptional<string>;
declare userId: ForeignKey<string>;
declare action: string;
declare entity: CreationOptional<string | null>;
declare entityId: CreationOptional<string | null>;
declare ipAddress: CreationOptional<string | null>;
declare metadata: CreationOptional<Record<string, unknown> | null>;
declare createdAt: CreationOptional<Date>;
}
UserActivityLog.init(
{
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
userId: { type: DataTypes.UUID, allowNull: false, field: "user_id" },
action: { type: DataTypes.TEXT, allowNull: false },
entity: { type: DataTypes.TEXT, allowNull: true },
entityId: { type: DataTypes.UUID, allowNull: true, field: "entity_id" },
ipAddress: { type: DataTypes.INET, allowNull: true, field: "ip_address" },
metadata: { type: DataTypes.JSONB, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: "created_at" },
},
{ sequelize, tableName: "user_activity_log", schema: "auth", modelName: "UserActivityLog", timestamps: false },
);
import pg from "pg";
import { config } from "../config.js";
const { Pool } = pg;
let _pool: pg.Pool | null = null;
export function getPool(): pg.Pool {
if (!_pool) {
_pool = new Pool({
connectionString: config.postgresUrl,
max: 30,
min: 5,
idleTimeoutMillis: 60_000,
connectionTimeoutMillis: 5_000,
allowExitOnIdle: false,
});
_pool.on("error", (err) => console.error("[pg-pool] idle client error", err));
}
return _pool;
}
/**
* Singleton Sequelize instance wired to the existing POSTGRES_URL.
* Existing pg-based repositories are untouched; this is available for new
* code or incremental migration off raw pg.
*/
import { Sequelize, QueryTypes, type Transaction } from "sequelize";
import { config } from "../config.js";
let _sequelize: Sequelize | null = null;
export function getSequelize(): Sequelize {
if (!_sequelize) {
_sequelize = new Sequelize(config.postgresUrl, {
dialect: "postgres",
logging: process.env["DEBUG_SEQUELIZE"] === "1" ? console.log : false,
pool: {
max: 30,
min: 5,
idle: 60_000,
acquire: 5_000,
},
define: {
timestamps: false,
underscored: true,
freezeTableName: true,
},
});
}
return _sequelize;
}
export const sequelize: Sequelize = getSequelize();
/**
* Drop-in replacement for the legacy `pg.Pool.query()` API, routed through
* Sequelize. Returns the same `{ rows, rowCount }` shape so existing
* repositories migrate without restructuring their result handling.
*
* SQL stays in Postgres-native `$1, $2` placeholder syntax via Sequelize's
* `bind` option — Sequelize forwards bound parameters directly to the pg
* driver so partition-pruning, jsonb, tsvector and other dialect-specific
* features continue to work.
*/
export async function query<T extends Record<string, unknown>>(
sql: string,
params: unknown[] = [],
options?: { transaction?: Transaction },
): Promise<{ rows: T[]; rowCount: number }> {
const trimmed = sql.trimStart().toUpperCase();
const isSelect =
trimmed.startsWith("SELECT") ||
trimmed.startsWith("WITH") ||
/\bRETURNING\b/i.test(sql);
if (isSelect) {
const rows = (await sequelize.query(sql, {
bind: params,
type: QueryTypes.SELECT,
transaction: options?.transaction,
})) as T[];
return { rows, rowCount: rows.length };
}
const [, affected] = (await sequelize.query(sql, {
bind: params,
transaction: options?.transaction,
})) as [unknown, number];
return { rows: [] as T[], rowCount: typeof affected === "number" ? affected : 0 };
}
/**
* Run `fn` inside a Sequelize transaction. The callback receives a `q`
* function with the same signature as the module-level `query` helper but
* scoped to the transaction.
*/
export async function withTransaction<T>(
fn: (q: typeof query) => Promise<T>,
): Promise<T> {
return sequelize.transaction(async (t) => {
const txQuery = <R extends Record<string, unknown>>(sql: string, params: unknown[] = []) =>
query<R>(sql, params, { transaction: t });
return fn(txQuery as typeof query);
});
}
import { Op } from "sequelize";
import {
Role,
SecurityEvent,
User,
UserPassword,
UserSsoAccount,
} from "../../db/models/auth.js";
export interface AuthenticatedUser {
id: string;
email: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
roles: string[];
isActive: boolean;
isLocked: boolean;
}
function toAuthUser(u: User, roleCodes: string[]): AuthenticatedUser {
return {
id: u.id,
email: u.email,
username: u.username,
displayName: u.displayName ?? null,
avatarUrl: u.avatarUrl ?? null,
roles: roleCodes,
isActive: u.isActive,
isLocked: u.isLocked,
};
}
async function rolesForUserId(userId: string): Promise<string[]> {
const rows = await Role.findAll({
attributes: ["code", "name"],
include: [
{
association: Role.associations["users"]!,
attributes: [],
where: { id: userId },
required: true,
through: { attributes: [] },
},
],
});
return rows.map((r) => r.code ?? r.name);
}
export const authRepository = {
async findUserByEmail(email: string): Promise<User | null> {
return User.findOne({
where: {
email: { [Op.iLike]: email },
isDeleted: false,
},
});
},
async findUserById(id: string): Promise<User | null> {
return User.findOne({ where: { id, isDeleted: false } });
},
async getPasswordHash(userId: string): Promise<string | null> {
const row = await UserPassword.findOne({ where: { userId } });
return row?.passwordHash ?? null;
},
async getRoleCodesForUser(userId: string): Promise<string[]> {
return rolesForUserId(userId);
},
async toAuthUser(u: User): Promise<AuthenticatedUser> {
const roles = await rolesForUserId(u.id);
return toAuthUser(u, roles);
},
async recordSuccessfulLogin(userId: string, ip: string | null): Promise<void> {
await User.update(
{
lastLoginAt: new Date(),
failedLoginCount: 0,
lastFailedLoginAt: null,
updatedAt: new Date(),
},
{ where: { id: userId } },
);
await SecurityEvent.create({
userId,
eventType: "login.success",
severity: "info",
details: ip ? { ip } : null,
});
},
async recordFailedLogin(userId: string, ip: string | null): Promise<void> {
const user = await User.findByPk(userId);
if (!user) return;
const next = user.failedLoginCount + 1;
const lockUntil = next >= 5
? new Date(Date.now() + 15 * 60_000)
: null;
await User.update(
{
failedLoginCount: next,
lastFailedLoginAt: new Date(),
lockedUntil: lockUntil,
isLocked: lockUntil !== null,
updatedAt: new Date(),
},
{ where: { id: userId } },
);
await SecurityEvent.create({
userId,
eventType: "login.failure",
severity: lockUntil ? "warn" : "info",
details: ip ? { ip, attempt: next } : { attempt: next },
});
},
async findOrLinkSsoAccount(args: {
provider: string;
providerUserId: string;
email: string;
displayName: string | null;
}): Promise<User> {
const link = await UserSsoAccount.findOne({
where: { provider: args.provider, providerUserId: args.providerUserId },
});
if (link) {
const u = await User.findByPk(link.userId);
if (u) return u;
}
// No SSO link yet — match by email to existing user, else create.
let user = await User.findOne({
where: { email: { [Op.iLike]: args.email }, isDeleted: false },
});
if (!user) {
user = await User.create({
email: args.email,
username: args.email,
displayName: args.displayName,
authProvider: args.provider,
isActive: true,
});
}
await UserSsoAccount.findOrCreate({
where: { provider: args.provider, providerUserId: args.providerUserId },
defaults: {
userId: user.id,
provider: args.provider,
providerUserId: args.providerUserId,
email: args.email,
},
});
return user;
},
};
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
issueJwt,
resolveSsoUser,
verifyPassword,
} from "./service.js";
const CredentialsBody = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const SsoBody = z.object({
provider: z.string().min(1),
providerUserId: z.string().min(1),
email: z.string().email(),
displayName: z.string().nullable().optional(),
});
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
// Used by NextAuth Credentials provider's authorize() callback.
app.post("/api/auth/credentials/verify", async (request, reply) => {
const parsed = CredentialsBody.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid body" });
}
const ip = request.ip ?? null;
const user = await verifyPassword(parsed.data.email, parsed.data.password, ip);
if (!user) {
return reply.code(401).send({ message: "Invalid credentials" });
}
const token = await issueJwt(user);
return reply.send({ user, token });
});
// Used by NextAuth SSO providers' signIn() callback to resolve/create
// the local user record and mint a backend-trusted JWT.
app.post("/api/auth/sso/exchange", async (request, reply) => {
const parsed = SsoBody.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid body" });
}
const ip = request.ip ?? null;
const user = await resolveSsoUser({
provider: parsed.data.provider,
providerUserId: parsed.data.providerUserId,
email: parsed.data.email,
displayName: parsed.data.displayName ?? null,
ip,
});
const token = await issueJwt(user);
return reply.send({ user, token });
});
// Health check that requires a valid JWT (useful from the frontend to
// verify the token round-trip end-to-end).
app.get("/api/auth/me", async (request, reply) => {
const claims = request.authUser;
if (!claims) return reply.code(401).send({ message: "Unauthenticated" });
return reply.send(claims);
});
}
import bcrypt from "bcrypt";
import { SignJWT, jwtVerify } from "jose";
import { config } from "../../config.js";
import { authRepository, type AuthenticatedUser } from "./repository.js";
const ISSUER = "maf-gateway";
const AUDIENCE = "maf-frontend";
const TOKEN_TTL_SECONDS = 60 * 60 * 8; // 8h
const secretKey = (): Uint8Array => new TextEncoder().encode(config.auth.jwtSecret);
export interface JwtClaims {
sub: string; // user id
email: string;
username: string;
displayName: string | null;
roles: string[];
iat: number;
exp: number;
iss: string;
aud: string;
}
/** Verify an email+password pair against the auth.user_passwords hash. */
export async function verifyPassword(
email: string,
password: string,
ip: string | null,
): Promise<AuthenticatedUser | null> {
const user = await authRepository.findUserByEmail(email);
if (!user) return null;
if (!user.isActive || user.isDeleted) return null;
if (user.isLocked && user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
return null;
}
const hash = await authRepository.getPasswordHash(user.id);
if (!hash) return null;
const ok = await bcrypt.compare(password, hash);
if (!ok) {
await authRepository.recordFailedLogin(user.id, ip);
return null;
}
await authRepository.recordSuccessfulLogin(user.id, ip);
return authRepository.toAuthUser(user);
}
/** Resolve (or create) the local user backing an external SSO login. */
export async function resolveSsoUser(args: {
provider: string;
providerUserId: string;
email: string;
displayName: string | null;
ip: string | null;
}): Promise<AuthenticatedUser> {
const u = await authRepository.findOrLinkSsoAccount({
provider: args.provider,
providerUserId: args.providerUserId,
email: args.email,
displayName: args.displayName,
});
await authRepository.recordSuccessfulLogin(u.id, args.ip);
return authRepository.toAuthUser(u);
}
export async function issueJwt(user: AuthenticatedUser): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({
email: user.email,
username: user.username,
displayName: user.displayName,
roles: user.roles,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt(now)
.setExpirationTime(now + TOKEN_TTL_SECONDS)
.sign(secretKey());
}
export async function verifyJwt(token: string): Promise<JwtClaims> {
const { payload } = await jwtVerify(token, secretKey(), {
issuer: ISSUER,
audience: AUDIENCE,
});
return payload as unknown as JwtClaims;
}
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type pg from "pg"; import { query, withTransaction } from "../../db/sequelize.js";
import { getPool } from "../../db/pg-pool.js";
import type { import type {
CatalogOption, CatalogOption,
CreateFormInput, CreateFormInput,
...@@ -118,11 +117,6 @@ function toSubmissionRecord(row: Record<string, unknown>): SubmissionRecord { ...@@ -118,11 +117,6 @@ function toSubmissionRecord(row: Record<string, unknown>): SubmissionRecord {
// ─── Repository ─────────────────────────────────────────────────────────────── // ─── Repository ───────────────────────────────────────────────────────────────
export class PgFormRepository implements FormRepository { export class PgFormRepository implements FormRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
async listForms(filter?: import("./domain.js").ListFormsFilter): Promise<FormRecord[]> { async listForms(filter?: import("./domain.js").ListFormsFilter): Promise<FormRecord[]> {
const where: string[] = ["deleted_at IS NULL"]; const where: string[] = ["deleted_at IS NULL"];
...@@ -160,7 +154,7 @@ export class PgFormRepository implements FormRepository { ...@@ -160,7 +154,7 @@ export class PgFormRepository implements FormRepository {
scopeFilter("entity_ids", filter?.entityId); scopeFilter("entity_ids", filter?.entityId);
scopeFilter("bu_ids", filter?.buId); scopeFilter("bu_ids", filter?.buId);
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT ${FORM_COLUMNS} `SELECT ${FORM_COLUMNS}
FROM forms.form FROM forms.form
WHERE ${where.join(" AND ")} WHERE ${where.join(" AND ")}
...@@ -193,7 +187,7 @@ export class PgFormRepository implements FormRepository { ...@@ -193,7 +187,7 @@ export class PgFormRepository implements FormRepository {
push("country_id", filter.countryId); push("country_id", filter.countryId);
// First try assignment-based query // First try assignment-based query
const r = await this.pool.query<Record<string, unknown>>( const r = await query<Record<string, unknown>>(
`SELECT DISTINCT u.id, u.email, u.display_name, u.first_name, u.last_name, r.name AS role `SELECT DISTINCT u.id, u.email, u.display_name, u.first_name, u.last_name, r.name AS role
FROM auth.users u FROM auth.users u
JOIN auth.user_org_assignment uoa ON uoa.user_id = u.id JOIN auth.user_org_assignment uoa ON uoa.user_id = u.id
...@@ -217,7 +211,7 @@ export class PgFormRepository implements FormRepository { ...@@ -217,7 +211,7 @@ export class PgFormRepository implements FormRepository {
} }
// Fallback: return active users so the dropdown is never empty. // Fallback: return active users so the dropdown is never empty.
const r = await this.pool.query<Record<string, unknown>>( const r = await query<Record<string, unknown>>(
`SELECT u.id, u.email, u.display_name, u.first_name, u.last_name `SELECT u.id, u.email, u.display_name, u.first_name, u.last_name
FROM auth.users u FROM auth.users u
WHERE ${where.join(" AND ")} WHERE ${where.join(" AND ")}
...@@ -235,7 +229,7 @@ export class PgFormRepository implements FormRepository { ...@@ -235,7 +229,7 @@ export class PgFormRepository implements FormRepository {
} }
async getFormByCode(code: string): Promise<FormWithVersions | null> { async getFormByCode(code: string): Promise<FormWithVersions | null> {
const formResult = await this.pool.query<Record<string, unknown>>( const formResult = await query<Record<string, unknown>>(
`SELECT ${FORM_COLUMNS} `SELECT ${FORM_COLUMNS}
FROM forms.form FROM forms.form
WHERE code = $1 AND deleted_at IS NULL`, WHERE code = $1 AND deleted_at IS NULL`,
...@@ -245,7 +239,7 @@ export class PgFormRepository implements FormRepository { ...@@ -245,7 +239,7 @@ export class PgFormRepository implements FormRepository {
const formRow = formResult.rows[0]!; const formRow = formResult.rows[0]!;
const form = toFormRecord(formRow); const form = toFormRecord(formRow);
const versionsResult = await this.pool.query<Record<string, unknown>>( const versionsResult = await query<Record<string, unknown>>(
`SELECT id, form_id, semver, schema_checksum, survey_json, question_index, `SELECT id, form_id, semver, schema_checksum, survey_json, question_index,
is_published, published_at, created_at is_published, published_at, created_at
FROM forms.form_version FROM forms.form_version
...@@ -269,7 +263,7 @@ export class PgFormRepository implements FormRepository { ...@@ -269,7 +263,7 @@ export class PgFormRepository implements FormRepository {
} }
async getFormVersion(formId: string, semver: string): Promise<FormVersionRecord | null> { async getFormVersion(formId: string, semver: string): Promise<FormVersionRecord | null> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT id, form_id, semver, schema_checksum, survey_json, question_index, `SELECT id, form_id, semver, schema_checksum, survey_json, question_index,
is_published, published_at, created_at is_published, published_at, created_at
FROM forms.form_version FROM forms.form_version
...@@ -283,7 +277,7 @@ export class PgFormRepository implements FormRepository { ...@@ -283,7 +277,7 @@ export class PgFormRepository implements FormRepository {
async createForm(data: CreateFormInput): Promise<FormRecord> { async createForm(data: CreateFormInput): Promise<FormRecord> {
// Resolve module_id (NOT NULL constraint) before opening the transaction // Resolve module_id (NOT NULL constraint) before opening the transaction
const moduleCode = data.moduleCode ?? "audit"; const moduleCode = data.moduleCode ?? "audit";
const moduleRow = await this.pool.query<{ id: string }>( const moduleRow = await query<{ id: string }>(
`SELECT id FROM platform.module WHERE code = $1 AND is_active = TRUE LIMIT 1`, `SELECT id FROM platform.module WHERE code = $1 AND is_active = TRUE LIMIT 1`,
[moduleCode] [moduleCode]
); );
...@@ -292,19 +286,16 @@ export class PgFormRepository implements FormRepository { ...@@ -292,19 +286,16 @@ export class PgFormRepository implements FormRepository {
} }
const moduleId = moduleRow.rows[0]!.id; const moduleId = moduleRow.rows[0]!.id;
const client = await this.pool.connect(); return withTransaction(async (q) => {
try {
await client.query("BEGIN");
// 1. Insert form (settings have safe defaults; apply overrides if provided) // 1. Insert form (settings have safe defaults; apply overrides if provided)
const insertResult = await client.query<{ id: string }>( const insertResult = await q<{ id: string }>(
`INSERT INTO forms.form (module_id, code, display_name, description, is_active) `INSERT INTO forms.form (module_id, code, display_name, description, is_active)
VALUES ($1, $2, $3, $4, TRUE) VALUES ($1, $2, $3, $4, TRUE)
RETURNING id`, RETURNING id`,
[moduleId, data.code, data.displayName, data.description ?? null] [moduleId, data.code, data.displayName, data.description ?? null]
); );
const formId = insertResult.rows[0]!.id; const formId = insertResult.rows[0]!.id;
const semver = data.semver ?? "1.0.0"; void (data.semver ?? "1.0.0");
if (data.settings) { if (data.settings) {
const vals: unknown[] = []; const vals: unknown[] = [];
...@@ -312,7 +303,7 @@ export class PgFormRepository implements FormRepository { ...@@ -312,7 +303,7 @@ export class PgFormRepository implements FormRepository {
buildSettingsAssignments(data.settings, vals, sets); buildSettingsAssignments(data.settings, vals, sets);
if (sets.length > 0) { if (sets.length > 0) {
vals.push(formId); vals.push(formId);
await client.query( await q(
`UPDATE forms.form SET ${sets.join(", ")}, updated_at = NOW() `UPDATE forms.form SET ${sets.join(", ")}, updated_at = NOW()
WHERE id = $${vals.length}`, WHERE id = $${vals.length}`,
vals, vals,
...@@ -321,34 +312,24 @@ export class PgFormRepository implements FormRepository { ...@@ -321,34 +312,24 @@ export class PgFormRepository implements FormRepository {
} }
// 2. Insert initial unpublished draft (published on explicit publish action) // 2. Insert initial unpublished draft (published on explicit publish action)
await client.query<Record<string, unknown>>( await q<Record<string, unknown>>(
`INSERT INTO forms.form_version `INSERT INTO forms.form_version
(form_id, semver, survey_json, question_index, schema_checksum, is_published) (form_id, semver, survey_json, question_index, schema_checksum, is_published)
VALUES ($1, 'draft', $2, '{}', $3, FALSE)`, VALUES ($1, 'draft', $2, '{}', $3, FALSE)`,
[formId, JSON.stringify(data.surveyJson), data.schemaChecksum] [formId, JSON.stringify(data.surveyJson), data.schemaChecksum]
); );
await client.query("COMMIT");
// Return fresh form row with current_version_id set // Return fresh form row with current_version_id set
const refreshed = await this.pool.query<Record<string, unknown>>( const refreshed = await q<Record<string, unknown>>(
`SELECT ${FORM_COLUMNS} FROM forms.form WHERE id = $1`, `SELECT ${FORM_COLUMNS} FROM forms.form WHERE id = $1`,
[formId], [formId],
); );
return toFormRecord(refreshed.rows[0]!); return toFormRecord(refreshed.rows[0]!);
} catch (err) { });
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} }
async updateForm(code: string, data: UpdateFormInput): Promise<FormWithVersions | null> { async updateForm(code: string, data: UpdateFormInput): Promise<FormWithVersions | null> {
const client = await this.pool.connect(); const updated = await withTransaction(async (q) => {
try {
await client.query("BEGIN");
// 1. Update form metadata (only fields provided) // 1. Update form metadata (only fields provided)
const sets: string[] = ["updated_at = NOW()"]; const sets: string[] = ["updated_at = NOW()"];
const vals: unknown[] = []; const vals: unknown[] = [];
...@@ -361,43 +342,38 @@ export class PgFormRepository implements FormRepository { ...@@ -361,43 +342,38 @@ export class PgFormRepository implements FormRepository {
if (data.settings) buildSettingsAssignments(data.settings, vals, sets); if (data.settings) buildSettingsAssignments(data.settings, vals, sets);
vals.push(code); vals.push(code);
const formResult = await client.query<Record<string, unknown>>( const formResult = await q<Record<string, unknown>>(
`UPDATE forms.form SET ${sets.join(", ")} `UPDATE forms.form SET ${sets.join(", ")}
WHERE code = $${vals.length} AND deleted_at IS NULL WHERE code = $${vals.length} AND deleted_at IS NULL
RETURNING ${FORM_COLUMNS}`, RETURNING ${FORM_COLUMNS}`,
vals vals
); );
if ((formResult.rowCount ?? 0) === 0) { await client.query("ROLLBACK"); return null; } if ((formResult.rowCount ?? 0) === 0) return false;
const formRow = formResult.rows[0]!; const formRow = formResult.rows[0]!;
const formId = formRow["id"] as string; const formId = formRow["id"] as string;
// 2. If surveyJson provided — upsert draft version // 2. If surveyJson provided — upsert draft version
if (data.surveyJson !== undefined) { if (data.surveyJson !== undefined) {
const checksum = data.schemaChecksum ?? ""; const checksum = data.schemaChecksum ?? "";
// Find existing unpublished draft (pull checksum so we can skip const draftResult = await q<{ id: string; semver: string; schema_checksum: string }>(
// the JSON write when nothing has changed — a 1.5 MB draft round-trip
// becomes a no-op metadata UPDATE instead of a full TOAST rewrite).
const draftResult = await client.query<{ id: string; semver: string; schema_checksum: string }>(
`SELECT id, semver, schema_checksum FROM forms.form_version `SELECT id, semver, schema_checksum FROM forms.form_version
WHERE form_id = $1 AND is_published = FALSE WHERE form_id = $1 AND is_published = FALSE
ORDER BY created_at DESC LIMIT 1`, ORDER BY created_at DESC LIMIT 1`,
[formId] [formId]
); );
if (draftResult.rowCount! > 0) { if ((draftResult.rowCount ?? 0) > 0) {
const draft = draftResult.rows[0]!; const draft = draftResult.rows[0]!;
const checksumMatches = checksum.length > 0 && draft.schema_checksum === checksum; const checksumMatches = checksum.length > 0 && draft.schema_checksum === checksum;
if (checksumMatches) { if (checksumMatches) {
// Identical schema — only bump semver if the caller asked for it.
if (data.semver && data.semver !== draft.semver) { if (data.semver && data.semver !== draft.semver) {
await client.query( await q(
`UPDATE forms.form_version SET semver = $1 WHERE id = $2`, `UPDATE forms.form_version SET semver = $1 WHERE id = $2`,
[data.semver, draft.id] [data.semver, draft.id]
); );
} }
} else { } else {
// Update existing draft in-place with new JSON. await q(
await client.query(
`UPDATE forms.form_version `UPDATE forms.form_version
SET survey_json = $1, schema_checksum = $2, SET survey_json = $1, schema_checksum = $2,
semver = COALESCE($3, semver) semver = COALESCE($3, semver)
...@@ -406,38 +382,31 @@ export class PgFormRepository implements FormRepository { ...@@ -406,38 +382,31 @@ export class PgFormRepository implements FormRepository {
); );
} }
} else { } else {
// No draft — insert new draft version
const semver = data.semver ?? "draft"; const semver = data.semver ?? "draft";
const newVer = await client.query<{ id: string }>( const newVer = await q<{ id: string }>(
`INSERT INTO forms.form_version `INSERT INTO forms.form_version
(form_id, semver, survey_json, question_index, schema_checksum, is_published) (form_id, semver, survey_json, question_index, schema_checksum, is_published)
VALUES ($1, $2, $3, '{}', $4, FALSE) VALUES ($1, $2, $3, '{}', $4, FALSE)
RETURNING id`, RETURNING id`,
[formId, semver, JSON.stringify(data.surveyJson), checksum] [formId, semver, JSON.stringify(data.surveyJson), checksum]
); );
// Point current_version_id at draft only if form has no live version yet
if (!formRow["current_version_id"]) { if (!formRow["current_version_id"]) {
await client.query( await q(
`UPDATE forms.form SET current_version_id = $1 WHERE id = $2`, `UPDATE forms.form SET current_version_id = $1 WHERE id = $2`,
[newVer.rows[0]!.id, formId] [newVer.rows[0]!.id, formId]
); );
} }
} }
} }
return true;
});
await client.query("COMMIT"); if (!updated) return null;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
return this.getFormByCode(code); return this.getFormByCode(code);
} }
async softDeleteForm(code: string): Promise<boolean> { async softDeleteForm(code: string): Promise<boolean> {
const result = await this.pool.query( const result = await query(
`UPDATE forms.form SET deleted_at = NOW(), updated_at = NOW() `UPDATE forms.form SET deleted_at = NOW(), updated_at = NOW()
WHERE code = $1 AND deleted_at IS NULL`, WHERE code = $1 AND deleted_at IS NULL`,
[code] [code]
...@@ -446,12 +415,9 @@ export class PgFormRepository implements FormRepository { ...@@ -446,12 +415,9 @@ export class PgFormRepository implements FormRepository {
} }
async publishVersion(formId: string, data: PublishVersionInput): Promise<FormVersionRecord> { async publishVersion(formId: string, data: PublishVersionInput): Promise<FormVersionRecord> {
const client = await this.pool.connect(); return withTransaction(async (q) => {
try {
await client.query("BEGIN");
// 1. Promote existing unpublished draft if one exists; otherwise insert new published version // 1. Promote existing unpublished draft if one exists; otherwise insert new published version
const draftResult = await client.query<{ id: string }>( const draftResult = await q<{ id: string }>(
`SELECT id FROM forms.form_version WHERE form_id = $1 AND is_published = FALSE ORDER BY created_at DESC LIMIT 1`, `SELECT id FROM forms.form_version WHERE form_id = $1 AND is_published = FALSE ORDER BY created_at DESC LIMIT 1`,
[formId] [formId]
); );
...@@ -459,7 +425,7 @@ export class PgFormRepository implements FormRepository { ...@@ -459,7 +425,7 @@ export class PgFormRepository implements FormRepository {
let version: FormVersionRecord; let version: FormVersionRecord;
if ((draftResult.rowCount ?? 0) > 0) { if ((draftResult.rowCount ?? 0) > 0) {
const draftId = draftResult.rows[0]!.id; const draftId = draftResult.rows[0]!.id;
const updateResult = await client.query<Record<string, unknown>>( const updateResult = await q<Record<string, unknown>>(
`UPDATE forms.form_version `UPDATE forms.form_version
SET semver = $1, survey_json = $2, question_index = $3, SET semver = $1, survey_json = $2, question_index = $3,
schema_checksum = $4, is_published = TRUE, schema_checksum = $4, is_published = TRUE,
...@@ -471,9 +437,9 @@ export class PgFormRepository implements FormRepository { ...@@ -471,9 +437,9 @@ export class PgFormRepository implements FormRepository {
); );
version = toVersionRecord(updateResult.rows[0]!); version = toVersionRecord(updateResult.rows[0]!);
} else { } else {
let insertResult: pg.QueryResult<Record<string, unknown>>; let insertResult: { rows: Record<string, unknown>[]; rowCount: number };
try { try {
insertResult = await client.query<Record<string, unknown>>( insertResult = await q<Record<string, unknown>>(
`INSERT INTO forms.form_version `INSERT INTO forms.form_version
(form_id, semver, survey_json, question_index, schema_checksum, (form_id, semver, survey_json, question_index, schema_checksum,
is_published, published_at, published_by) is_published, published_at, published_by)
...@@ -483,8 +449,10 @@ export class PgFormRepository implements FormRepository { ...@@ -483,8 +449,10 @@ export class PgFormRepository implements FormRepository {
[formId, data.semver, JSON.stringify(data.surveyJson), JSON.stringify(data.questionIndex), data.schemaChecksum, data.publishedBy ?? null] [formId, data.semver, JSON.stringify(data.surveyJson), JSON.stringify(data.questionIndex), data.schemaChecksum, data.publishedBy ?? null]
); );
} catch (insertErr: unknown) { } catch (insertErr: unknown) {
const pgErr = insertErr as { code?: string }; // Sequelize wraps Postgres errors; the original code lives on `.parent`.
if (pgErr.code === "23505") { const parent = (insertErr as { parent?: { code?: string }; original?: { code?: string } })
.parent ?? (insertErr as { original?: { code?: string } }).original;
if (parent?.code === "23505") {
throw new Error(`Version ${data.semver} already exists for this form. Choose a different semver.`); throw new Error(`Version ${data.semver} already exists for this form. Choose a different semver.`);
} }
throw insertErr; throw insertErr;
...@@ -493,27 +461,21 @@ export class PgFormRepository implements FormRepository { ...@@ -493,27 +461,21 @@ export class PgFormRepository implements FormRepository {
} }
// 2. Update form's current_version_id // 2. Update form's current_version_id
await client.query( await q(
`UPDATE forms.form `UPDATE forms.form
SET current_version_id = $1, updated_at = NOW() SET current_version_id = $1, updated_at = NOW()
WHERE id = $2`, WHERE id = $2`,
[version.id, formId] [version.id, formId]
); );
await client.query("COMMIT");
return version; return version;
} catch (err) { });
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} }
async saveSubmission(data: SubmissionInput): Promise<SubmissionRecord> { async saveSubmission(data: SubmissionInput): Promise<SubmissionRecord> {
const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`; const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`;
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission `INSERT INTO forms.submission
(submitted_at, form_id, form_version_id, schema_checksum, module_code, (submitted_at, form_id, form_version_id, schema_checksum, module_code,
submitted_by, submission_code, status, answers) submitted_by, submission_code, status, answers)
...@@ -544,7 +506,7 @@ export class PgFormRepository implements FormRepository { ...@@ -544,7 +506,7 @@ export class PgFormRepository implements FormRepository {
const whereClause = "WHERE " + conditions.join(" AND "); const whereClause = "WHERE " + conditions.join(" AND ");
const countResult = await this.pool.query<{ count: string }>( const countResult = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`, `SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`,
params params
); );
...@@ -554,7 +516,7 @@ export class PgFormRepository implements FormRepository { ...@@ -554,7 +516,7 @@ export class PgFormRepository implements FormRepository {
const limit = filter.limit; const limit = filter.limit;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>( const dataResult = await query<Record<string, unknown>>(
`SELECT id, submission_code, form_id, form_version_id, module_code, `SELECT id, submission_code, form_id, form_version_id, module_code,
submitted_by, status, answers, submitted_at, created_at submitted_by, status, answers, submitted_at, created_at
FROM forms.submission FROM forms.submission
...@@ -579,25 +541,25 @@ export class PgFormRepository implements FormRepository { ...@@ -579,25 +541,25 @@ export class PgFormRepository implements FormRepository {
const [ const [
countries, organisations, subsidiaries, companies, entities, businessUnits, roles, countries, organisations, subsidiaries, companies, entities, businessUnits, roles,
] = await Promise.all([ ] = await Promise.all([
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_country ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_country ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_organisation ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_organisation ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_operating_subsidiaries ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_operating_subsidiaries ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_company ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_company ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_entity ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_entity ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_business_unit ${liveFilter} ${sortKey}`, `SELECT id, code, name FROM master.mst_business_unit ${liveFilter} ${sortKey}`,
), ),
this.pool.query<Record<string, unknown>>( query<Record<string, unknown>>(
`SELECT id, code, name FROM auth.roles `SELECT id, code, name FROM auth.roles
WHERE deleted_at IS NULL ORDER BY name`, WHERE deleted_at IS NULL ORDER BY name`,
), ),
......
import type pg from "pg"; import { query, withTransaction } from "../../db/sequelize.js";
import { getPool } from "../../db/pg-pool.js";
import { import {
resolveDomain, resolveDomain,
type CreateMasterInput, type CreateMasterInput,
...@@ -116,11 +115,6 @@ function buildSelectCols(cfg: DomainConfig): string { ...@@ -116,11 +115,6 @@ function buildSelectCols(cfg: DomainConfig): string {
// ─── Repository implementation ──────────────────────────────────────────────── // ─── Repository implementation ────────────────────────────────────────────────
export class PgMasterRepository implements MasterRepository { export class PgMasterRepository implements MasterRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
async list(domain: string, filter: MasterFilter): Promise<MasterPage> { async list(domain: string, filter: MasterFilter): Promise<MasterPage> {
const cfg = resolveDomain(domain); const cfg = resolveDomain(domain);
...@@ -165,7 +159,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -165,7 +159,7 @@ export class PgMasterRepository implements MasterRepository {
const { clause, params } = wb.build(); const { clause, params } = wb.build();
// COUNT // COUNT
const countResult = await this.pool.query<{ count: string }>( const countResult = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM ${cfg.table} ${clause}`, `SELECT COUNT(*) AS count FROM ${cfg.table} ${clause}`,
params params
); );
...@@ -176,7 +170,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -176,7 +170,7 @@ export class PgMasterRepository implements MasterRepository {
const limit = filter.limit; const limit = filter.limit;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>( const dataResult = await query<Record<string, unknown>>(
`SELECT ${cols} FROM ${cfg.table} ${clause} `SELECT ${cols} FROM ${cfg.table} ${clause}
ORDER BY sort_order ASC, ${nameCol} ASC ORDER BY sort_order ASC, ${nameCol} ASC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
...@@ -195,7 +189,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -195,7 +189,7 @@ export class PgMasterRepository implements MasterRepository {
const cfg = resolveDomain(domain); const cfg = resolveDomain(domain);
const cols = buildSelectCols(cfg); const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT ${cols} FROM ${cfg.table} WHERE id = $1 AND deleted_at IS NULL`, `SELECT ${cols} FROM ${cfg.table} WHERE id = $1 AND deleted_at IS NULL`,
[id] [id]
); );
...@@ -233,7 +227,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -233,7 +227,7 @@ export class PgMasterRepository implements MasterRepository {
const placeholders = colNames.map((_, i) => `$${i + 1}`).join(", "); const placeholders = colNames.map((_, i) => `$${i + 1}`).join(", ");
const cols = buildSelectCols(cfg); const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`INSERT INTO ${cfg.table} (${colNames.join(", ")}) `INSERT INTO ${cfg.table} (${colNames.join(", ")})
VALUES (${placeholders}) VALUES (${placeholders})
RETURNING ${cols}`, RETURNING ${cols}`,
...@@ -280,7 +274,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -280,7 +274,7 @@ export class PgMasterRepository implements MasterRepository {
const idIdx = setValues.length + 1; const idIdx = setValues.length + 1;
const cols = buildSelectCols(cfg); const cols = buildSelectCols(cfg);
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`UPDATE ${cfg.table} `UPDATE ${cfg.table}
SET ${setClauses.join(", ")} SET ${setClauses.join(", ")}
WHERE id = $${idIdx} AND deleted_at IS NULL WHERE id = $${idIdx} AND deleted_at IS NULL
...@@ -294,7 +288,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -294,7 +288,7 @@ export class PgMasterRepository implements MasterRepository {
async softDelete(domain: string, id: string): Promise<boolean> { async softDelete(domain: string, id: string): Promise<boolean> {
const cfg = resolveDomain(domain); const cfg = resolveDomain(domain);
const result = await this.pool.query( const result = await query(
`UPDATE ${cfg.table} `UPDATE ${cfg.table}
SET deleted_at = NOW() SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`, WHERE id = $1 AND deleted_at IS NULL`,
...@@ -309,7 +303,7 @@ export class PgMasterRepository implements MasterRepository { ...@@ -309,7 +303,7 @@ export class PgMasterRepository implements MasterRepository {
const cols = buildSelectCols(toCfg); const cols = buildSelectCols(toCfg);
// Inner join through the junction table → only related, undeleted target rows. // Inner join through the junction table → only related, undeleted target rows.
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT ${cols} `SELECT ${cols}
FROM ${toCfg.table} t FROM ${toCfg.table} t
INNER JOIN ${rel.table} j ON j.${rel.toColumn} = t.id INNER JOIN ${rel.table} j ON j.${rel.toColumn} = t.id
...@@ -324,26 +318,17 @@ export class PgMasterRepository implements MasterRepository { ...@@ -324,26 +318,17 @@ export class PgMasterRepository implements MasterRepository {
const rel = resolveRelationship(fromDomain, relName); const rel = resolveRelationship(fromDomain, relName);
const unique = [...new Set(toIds)]; const unique = [...new Set(toIds)];
const client = await this.pool.connect(); await withTransaction(async (q) => {
try {
await client.query("BEGIN");
// Replace-all semantics: drop existing edges, insert the new set. // Replace-all semantics: drop existing edges, insert the new set.
await client.query(`DELETE FROM ${rel.table} WHERE ${rel.fromColumn} = $1`, [fromId]); await q(`DELETE FROM ${rel.table} WHERE ${rel.fromColumn} = $1`, [fromId]);
if (unique.length > 0) { if (unique.length > 0) {
// Multi-row insert with one parameter per (fromId, toId) pair.
const placeholders = unique.map((_, i) => `($1, $${i + 2})`).join(", "); const placeholders = unique.map((_, i) => `($1, $${i + 2})`).join(", ");
await client.query( await q(
`INSERT INTO ${rel.table} (${rel.fromColumn}, ${rel.toColumn}) VALUES ${placeholders} `INSERT INTO ${rel.table} (${rel.fromColumn}, ${rel.toColumn}) VALUES ${placeholders}
ON CONFLICT DO NOTHING`, ON CONFLICT DO NOTHING`,
[fromId, ...unique], [fromId, ...unique],
); );
} }
await client.query("COMMIT"); });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} }
} }
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { Pool } from "pg";
import { z } from "zod"; import { z } from "zod";
import { config } from "../../config.js"; import { query } from "../../db/sequelize.js";
import { uploadToAzure } from "./azure-upload.js"; import { uploadToAzure } from "./azure-upload.js";
const pool = new Pool({ connectionString: config.postgresUrl });
const submitSchema = z.object({ const submitSchema = z.object({
form_id: z.string().min(1), form_id: z.string().min(1),
data: z.record(z.string(), z.unknown()), data: z.record(z.string(), z.unknown()),
...@@ -38,13 +35,13 @@ export async function registerPocRoutes(app: FastifyInstance): Promise<void> { ...@@ -38,13 +35,13 @@ export async function registerPocRoutes(app: FastifyInstance): Promise<void> {
const { form_id, data, files } = parsed.data; const { form_id, data, files } = parsed.data;
try { try {
const result = await pool.query<{ id: string; submitted_at: string }>( const result = await query<{ id: string; submitted_at: string }>(
`INSERT INTO form_responses (form_id, payload, files) `INSERT INTO form_responses (form_id, payload, files)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, submitted_at`, RETURNING id, submitted_at`,
[form_id, JSON.stringify(data), JSON.stringify(files)] [form_id, JSON.stringify(data), JSON.stringify(files)]
); );
const row = result.rows[0]; const row = result.rows[0]!;
return reply.code(201).send({ id: row.id, submitted_at: row.submitted_at }); return reply.code(201).send({ id: row.id, submitted_at: row.submitted_at });
} catch (err) { } catch (err) {
return reply.code(500).send({ message: `Submission failed: ${(err as Error).message}` }); return reply.code(500).send({ message: `Submission failed: ${(err as Error).message}` });
......
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type pg from "pg"; import { query } from "../../db/sequelize.js";
import { getPool } from "../../db/pg-pool.js";
import type { import type {
AttachFileInput, AttachFileInput,
CreateSubmissionInput, CreateSubmissionInput,
...@@ -93,17 +92,12 @@ function toEvent(row: Record<string, unknown>): SubmissionEventRecord { ...@@ -93,17 +92,12 @@ function toEvent(row: Record<string, unknown>): SubmissionEventRecord {
// ─── Repository ─────────────────────────────────────────────────────────────── // ─── Repository ───────────────────────────────────────────────────────────────
export class PgSubmissionRepository { export class PgSubmissionRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
/** Look up surveyJson for a form version — used by the score calculator. */ /** Look up surveyJson for a form version — used by the score calculator. */
async getFormVersionSurveyJson( async getFormVersionSurveyJson(
formVersionId: string, formVersionId: string,
): Promise<Record<string, unknown> | null> { ): Promise<Record<string, unknown> | null> {
const r = await this.pool.query<{ survey_json: Record<string, unknown> }>( const r = await query<{ survey_json: Record<string, unknown> }>(
`SELECT survey_json FROM forms.form_version WHERE id = $1`, `SELECT survey_json FROM forms.form_version WHERE id = $1`,
[formVersionId], [formVersionId],
); );
...@@ -115,7 +109,7 @@ export class PgSubmissionRepository { ...@@ -115,7 +109,7 @@ export class PgSubmissionRepository {
async create(data: CreateSubmissionInput): Promise<SubmissionRecord> { async create(data: CreateSubmissionInput): Promise<SubmissionRecord> {
const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`; const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`;
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission ( `INSERT INTO forms.submission (
submitted_at, form_id, form_version_id, schema_checksum, module_code, submitted_at, form_id, form_version_id, schema_checksum, module_code,
submitted_by, submission_code, status, answers, submitted_by, submission_code, status, answers,
...@@ -168,14 +162,14 @@ export class PgSubmissionRepository { ...@@ -168,14 +162,14 @@ export class PgSubmissionRepository {
params.push(submittedAt); params.push(submittedAt);
conditions.push(tsWindow("submitted_at", params.length)); conditions.push(tsWindow("submitted_at", params.length));
} }
const subResult = await this.pool.query<Record<string, unknown>>( const subResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission WHERE ${conditions.join(" AND ")} LIMIT 1`, `SELECT * FROM forms.submission WHERE ${conditions.join(" AND ")} LIMIT 1`,
params, params,
); );
if (subResult.rowCount === 0) return null; if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!); const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>( const filesResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file `SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`, ORDER BY uploaded_at DESC`,
...@@ -185,7 +179,7 @@ export class PgSubmissionRepository { ...@@ -185,7 +179,7 @@ export class PgSubmissionRepository {
} }
async getByCode(code: string): Promise<SubmissionWithFiles | null> { async getByCode(code: string): Promise<SubmissionWithFiles | null> {
const subResult = await this.pool.query<Record<string, unknown>>( const subResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission `SELECT * FROM forms.submission
WHERE submission_code = $1 AND deleted_at IS NULL WHERE submission_code = $1 AND deleted_at IS NULL
ORDER BY submitted_at DESC LIMIT 1`, ORDER BY submitted_at DESC LIMIT 1`,
...@@ -194,7 +188,7 @@ export class PgSubmissionRepository { ...@@ -194,7 +188,7 @@ export class PgSubmissionRepository {
if (subResult.rowCount === 0) return null; if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!); const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>( const filesResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file `SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND submission_submitted_at = $2 AND deleted_at IS NULL WHERE submission_id = $1 AND submission_submitted_at = $2 AND deleted_at IS NULL
ORDER BY uploaded_at DESC`, ORDER BY uploaded_at DESC`,
...@@ -240,7 +234,7 @@ export class PgSubmissionRepository { ...@@ -240,7 +234,7 @@ export class PgSubmissionRepository {
vals.push(submittedAt); vals.push(submittedAt);
const idIdx = vals.length - 1; const idIdx = vals.length - 1;
const tsIdx = vals.length; const tsIdx = vals.length;
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`UPDATE forms.submission SET ${sets.join(", ")} `UPDATE forms.submission SET ${sets.join(", ")}
WHERE id = $${idIdx} AND ${tsWindow("submitted_at", tsIdx)} WHERE id = $${idIdx} AND ${tsWindow("submitted_at", tsIdx)}
AND deleted_at IS NULL AND deleted_at IS NULL
...@@ -252,7 +246,7 @@ export class PgSubmissionRepository { ...@@ -252,7 +246,7 @@ export class PgSubmissionRepository {
} }
async softDelete(id: string, submittedAt: string): Promise<boolean> { async softDelete(id: string, submittedAt: string): Promise<boolean> {
const result = await this.pool.query( const result = await query(
`UPDATE forms.submission `UPDATE forms.submission
SET deleted_at = NOW(), updated_at = NOW() SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND ${tsWindow("submitted_at", 2)} AND deleted_at IS NULL`, WHERE id = $1 AND ${tsWindow("submitted_at", 2)} AND deleted_at IS NULL`,
...@@ -299,7 +293,7 @@ export class PgSubmissionRepository { ...@@ -299,7 +293,7 @@ export class PgSubmissionRepository {
const whereClause = "WHERE " + conditions.join(" AND "); const whereClause = "WHERE " + conditions.join(" AND ");
const countResult = await this.pool.query<{ count: string }>( const countResult = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`, `SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`,
params, params,
); );
...@@ -307,7 +301,7 @@ export class PgSubmissionRepository { ...@@ -307,7 +301,7 @@ export class PgSubmissionRepository {
const limit = filter.limit; const limit = filter.limit;
const offset = (filter.page - 1) * limit; const offset = (filter.page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>( const dataResult = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission `SELECT * FROM forms.submission
${whereClause} ${whereClause}
ORDER BY submitted_at DESC ORDER BY submitted_at DESC
...@@ -330,7 +324,7 @@ export class PgSubmissionRepository { ...@@ -330,7 +324,7 @@ export class PgSubmissionRepository {
submissionSubmittedAt: string, submissionSubmittedAt: string,
file: AttachFileInput, file: AttachFileInput,
): Promise<SubmissionFileRecord> { ): Promise<SubmissionFileRecord> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission_file ( `INSERT INTO forms.submission_file (
submission_id, submission_submitted_at, submission_id, submission_submitted_at,
question_code, file_name, blob_path, blob_container, question_code, file_name, blob_path, blob_container,
...@@ -358,7 +352,7 @@ export class PgSubmissionRepository { ...@@ -358,7 +352,7 @@ export class PgSubmissionRepository {
submissionId: string, submissionId: string,
submissionSubmittedAt: string, submissionSubmittedAt: string,
): Promise<SubmissionFileRecord[]> { ): Promise<SubmissionFileRecord[]> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file `SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`, ORDER BY uploaded_at DESC`,
...@@ -371,7 +365,7 @@ export class PgSubmissionRepository { ...@@ -371,7 +365,7 @@ export class PgSubmissionRepository {
fileId: string, fileId: string,
submissionSubmittedAt: string, submissionSubmittedAt: string,
): Promise<SubmissionFileRecord | null> { ): Promise<SubmissionFileRecord | null> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file `SELECT * FROM forms.submission_file
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
LIMIT 1`, LIMIT 1`,
...@@ -385,7 +379,7 @@ export class PgSubmissionRepository { ...@@ -385,7 +379,7 @@ export class PgSubmissionRepository {
fileId: string, fileId: string,
submissionSubmittedAt: string, submissionSubmittedAt: string,
): Promise<boolean> { ): Promise<boolean> {
const result = await this.pool.query( const result = await query(
`UPDATE forms.submission_file `UPDATE forms.submission_file
SET deleted_at = NOW() SET deleted_at = NOW()
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL`, WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL`,
...@@ -403,7 +397,7 @@ export class PgSubmissionRepository { ...@@ -403,7 +397,7 @@ export class PgSubmissionRepository {
actorUserId: string | null, actorUserId: string | null,
diff: Record<string, unknown> | null, diff: Record<string, unknown> | null,
): Promise<SubmissionEventRecord> { ): Promise<SubmissionEventRecord> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`INSERT INTO forms.submission_event ( `INSERT INTO forms.submission_event (
submission_id, submission_submitted_at, submission_id, submission_submitted_at,
event_type, actor_user_id, diff event_type, actor_user_id, diff
...@@ -422,7 +416,7 @@ export class PgSubmissionRepository { ...@@ -422,7 +416,7 @@ export class PgSubmissionRepository {
} }
async listEvents(submissionId: string): Promise<SubmissionEventRecord[]> { async listEvents(submissionId: string): Promise<SubmissionEventRecord[]> {
const result = await this.pool.query<Record<string, unknown>>( const result = await query<Record<string, unknown>>(
`SELECT * FROM forms.submission_event `SELECT * FROM forms.submission_event
WHERE submission_id = $1 WHERE submission_id = $1
ORDER BY occurred_at DESC, id DESC ORDER BY occurred_at DESC, id DESC
......
import type { SubmissionStatus } from "./domain.js"; import type { SubmissionStatus } from "./domain.js";
// ─── State machine ────────────────────────────────────────────────────────────
//
// draft ──► submitted ──► approved
// │ │
// │ └────► rejected ──► draft (revise)
// │
// ▼
// archived (terminal — any non-approved row can be archived)
//
// approved ──► archived (terminal)
const TRANSITIONS: Record<SubmissionStatus, SubmissionStatus[]> = { const TRANSITIONS: Record<SubmissionStatus, SubmissionStatus[]> = {
draft: ["submitted", "archived"], draft: ["submitted", "archived"],
......
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import { verifyJwt, type JwtClaims } from "../modules/auth/service.js";
const ALL_ROLES = ["Admin", "Form Builder", "Master Data Manager", "Read-only"] as const; declare module "fastify" {
const WRITERS = ["Admin", "Form Builder"] as const; interface FastifyRequest {
const ADMINS = ["Admin"] as const; authUser?: JwtClaims;
const MDM_WRITE = ["Admin", "Master Data Manager"] as const; }
}
/** Routes that don't require authentication. Anything else needs a valid JWT. */
const PUBLIC_PREFIXES = [
"/health",
"/api/auth/credentials/verify",
"/api/auth/sso/exchange",
];
/** /**
* Method-aware authorization matrix. * Role-based authorization matrix. The roles match `auth.roles.code`
* Each entry: [HTTP method ("*" = any), URL prefix, allowed roles]. * (admin, super_admin, area_manager, …). `*` permits any authenticated
* Entries are evaluated in order — first match wins. * caller; first matching entry wins.
* Empty roles array [] = public route.
*/ */
const MATRIX: Array<[string, string, readonly string[]]> = [ const WRITER_ROLES = ["admin", "super_admin", "new_admin"] as const;
// Public const ADMIN_ROLES = ["admin", "super_admin"] as const;
["*", "/health", []], const MDM_WRITE = ["admin", "super_admin", "function_head"] as const;
// Forms — read-only endpoints (GET)
["GET", "/api/forms/schema", ALL_ROLES], const MATRIX: Array<[string, string, readonly string[] | "*"]> = [
["GET", "/api/forms", ALL_ROLES], // Forms
// Forms — write endpoints ["GET", "/api/forms", "*"],
["POST", "/api/forms/submissions", WRITERS], ["POST", "/api/forms/submissions", WRITER_ROLES],
["POST", "/api/forms", WRITERS], ["POST", "/api/forms", WRITER_ROLES],
["PATCH", "/api/forms", WRITERS], ["PATCH", "/api/forms", WRITER_ROLES],
["DELETE", "/api/forms", ADMINS], ["DELETE", "/api/forms", ADMIN_ROLES],
// Submissions (separate module) — all authenticated roles can read // Submissions
["GET", "/api/submissions", ALL_ROLES], ["GET", "/api/submissions", "*"],
["POST", "/api/submissions", ALL_ROLES], ["POST", "/api/submissions", "*"],
["PATCH", "/api/submissions", ALL_ROLES], ["PATCH", "/api/submissions", "*"],
["DELETE", "/api/submissions", WRITERS], ["DELETE", "/api/submissions", WRITER_ROLES],
// Master data — read // Master data
["GET", "/api/master-data", ALL_ROLES], ["GET", "/api/master-data", "*"],
// Master data — write ["POST", "/api/master-data", MDM_WRITE],
["POST", "/api/master-data", MDM_WRITE], ["PATCH", "/api/master-data", MDM_WRITE],
["PATCH", "/api/master-data", MDM_WRITE], ["PUT", "/api/master-data", MDM_WRITE],
["PUT", "/api/master-data", MDM_WRITE], ["DELETE", "/api/master-data", ADMIN_ROLES],
["DELETE", "/api/master-data", ADMINS], // Auth helpers
["GET", "/api/auth/me", "*"],
]; ];
function isPublic(path: string): boolean {
return PUBLIC_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`) || path.startsWith(`${p}?`));
}
function tokenFrom(request: FastifyRequest): string | null {
const header = request.headers["authorization"];
if (typeof header === "string" && header.toLowerCase().startsWith("bearer ")) {
return header.slice(7).trim();
}
return null;
}
function methodAllowed(method: string, path: string, roles: string[]): {
match: boolean;
allowed: boolean;
} {
for (const [entryMethod, prefix, allow] of MATRIX) {
if (!path.startsWith(prefix)) continue;
if (entryMethod !== method) continue;
if (allow === "*") return { match: true, allowed: true };
return {
match: true,
allowed: roles.some((r) => (allow as readonly string[]).includes(r)),
};
}
return { match: false, allowed: false };
}
export async function authorize(request: FastifyRequest, reply: FastifyReply): Promise<void> { export async function authorize(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const role = (request.headers["x-maf-role"] as string | undefined) ?? "Read-only";
const method = request.method.toUpperCase(); const method = request.method.toUpperCase();
const path = request.url.split("?")[0] ?? ""; const path = request.url.split("?")[0] ?? "";
for (const [entryMethod, prefix, allowed] of MATRIX) { if (isPublic(path)) return;
if (!path.startsWith(prefix)) continue;
if (entryMethod !== "*" && entryMethod !== method) continue;
if (allowed.length === 0) return; // public const token = tokenFrom(request);
if ((allowed as string[]).includes(role)) return; // authorized if (!token) {
return void reply.code(401).send({ message: "Missing bearer token" });
}
let claims: JwtClaims;
try {
claims = await verifyJwt(token);
} catch {
return void reply.code(401).send({ message: "Invalid or expired token" });
}
request.authUser = claims;
const { match, allowed } = methodAllowed(method, path, claims.roles ?? []);
if (!match) {
return void reply.code(403).send({ message: "Forbidden: route not permitted" });
}
if (!allowed) {
return void reply.code(403).send({ message: "Forbidden for current role" }); return void reply.code(403).send({ message: "Forbidden for current role" });
} }
return void reply.code(403).send({ message: "Forbidden: route not permitted" });
} }
...@@ -8,15 +8,12 @@ import { registerFormRoutes } from "./modules/forms/routes.js"; ...@@ -8,15 +8,12 @@ import { registerFormRoutes } from "./modules/forms/routes.js";
import { registerMasterDataRoutes } from "./modules/masterData/routes.js"; import { registerMasterDataRoutes } from "./modules/masterData/routes.js";
import { registerPocRoutes } from "./modules/poc/routes.js"; import { registerPocRoutes } from "./modules/poc/routes.js";
import { registerSubmissionRoutes } from "./modules/submissions/routes.js"; import { registerSubmissionRoutes } from "./modules/submissions/routes.js";
import { registerAuthRoutes } from "./modules/auth/routes.js";
const app = Fastify({ const app = Fastify({
logger: process.env["NODE_ENV"] !== "production", logger: process.env["NODE_ENV"] !== "production",
disableRequestLogging: process.env["NODE_ENV"] === "production", disableRequestLogging: process.env["NODE_ENV"] === "production",
ajv: { customOptions: { removeAdditional: false } }, ajv: { customOptions: { removeAdditional: false } },
// SurveyJS form schemas (with many pages, choices, and inline help text)
// routinely exceed Fastify's 1 MB default. Bump to 16 MB so PATCH/POST of
// form definitions doesn't trip a 413 (which the browser surfaces as a
// CORS error because the request connection closes mid-flight).
bodyLimit: 16 * 1024 * 1024, bodyLimit: 16 * 1024 * 1024,
}); });
...@@ -27,7 +24,7 @@ await app.register(cors, { ...@@ -27,7 +24,7 @@ await app.register(cors, {
"content-type", "content-type",
"if-match", "if-match",
"if-none-match", "if-none-match",
"x-maf-role", "authorization",
"x-tenant-id", "x-tenant-id",
"x-confirm-delete-all", "x-confirm-delete-all",
], ],
...@@ -46,6 +43,7 @@ await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } }); ...@@ -46,6 +43,7 @@ await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
app.addHook("preHandler", authorize); app.addHook("preHandler", authorize);
await registerAuthRoutes(app);
await registerFormRoutes(app); await registerFormRoutes(app);
await registerMasterDataRoutes(app); await registerMasterDataRoutes(app);
await registerPocRoutes(app); await registerPocRoutes(app);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment