SMS gone, approvals in

This commit is contained in:
2026-10-01 13:05:10 +05:30
parent 075b6b9968
commit 6937b4e114
11 changed files with 350 additions and 175 deletions
+1 -2
View File
@@ -1,7 +1,6 @@
DATABASE_URL="file:../data/app.db" DATABASE_URL="file:../data/app.db"
JWT_SECRET="replace-with-a-long-random-string" JWT_SECRET="replace-with-a-long-random-string"
TEXTLK_API_TOKEN=
TEXTLK_SENDER_ID=TextLKDemo
SEED_ADMIN_NAME="Super Admin" SEED_ADMIN_NAME="Super Admin"
SEED_ADMIN_PHONE="0715127342" SEED_ADMIN_PHONE="0715127342"
SEED_ADMIN_PASSWORD="change-this-password"
PORT=3000 PORT=3000
+13 -38
View File
@@ -11,14 +11,14 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2",
},
"devDependencies": {
"prisma": "^6.16.2" "prisma": "^6.16.2"
} },
"devDependencies": {}
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.3", "version": "6.19.3",
@@ -46,7 +46,6 @@
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"c12": "3.1.0", "c12": "3.1.0",
@@ -59,14 +58,12 @@
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -80,14 +77,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.3", "@prisma/debug": "6.19.3",
@@ -99,7 +94,6 @@
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.19.3" "@prisma/debug": "6.19.3"
@@ -109,7 +103,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -184,6 +177,15 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.8", "version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -227,7 +229,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -285,7 +286,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
@@ -301,7 +301,6 @@
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"consola": "^3.2.3" "consola": "^3.2.3"
@@ -323,14 +322,12 @@
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/consola": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.18.0 || >=16.10.0" "node": "^14.18.0 || >=16.10.0"
@@ -402,7 +399,6 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"devOptional": true,
"funding": [ "funding": [
{ {
"type": "ko-fi", "type": "ko-fi",
@@ -422,7 +418,6 @@
"version": "6.1.7", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
@@ -447,7 +442,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/destroy": { "node_modules/destroy": {
@@ -505,7 +499,6 @@
"version": "3.21.0", "version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -516,7 +509,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -641,14 +633,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "3.23.2", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -789,7 +779,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"citty": "^0.1.6", "citty": "^0.1.6",
@@ -941,7 +930,6 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -1126,14 +1114,12 @@
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.10", "version": "0.6.10",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.10.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.10.tgz",
"integrity": "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==", "integrity": "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"citty": "^0.2.2", "citty": "^0.2.2",
@@ -1151,7 +1137,6 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": { "node_modules/object-assign": {
@@ -1179,7 +1164,6 @@
"version": "2.0.12", "version": "2.0.12",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/on-finished": { "node_modules/on-finished": {
@@ -1213,21 +1197,18 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz",
"integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"confbox": "^0.3.1", "confbox": "^0.3.1",
@@ -1239,14 +1220,12 @@
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -1298,7 +1277,6 @@
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -1355,7 +1333,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"defu": "^6.1.4", "defu": "^6.1.4",
@@ -1366,7 +1343,6 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
@@ -1550,7 +1526,6 @@
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+3 -4
View File
@@ -5,7 +5,7 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"start": "node src/index.js", "start": "prisma migrate deploy && node prisma/seed.js && node src/index.js",
"seed": "node prisma/seed.js", "seed": "node prisma/seed.js",
"check": "node scripts/api-check.js", "check": "node scripts/api-check.js",
"postinstall": "prisma generate" "postinstall": "prisma generate"
@@ -16,12 +16,11 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2",
},
"devDependencies": {
"prisma": "^6.16.2" "prisma": "^6.16.2"
}, },
"overrides": { "overrides": {
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "LoginRequest" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"secretHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"decidedById" TEXT,
"decidedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "LoginRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"role" TEXT NOT NULL,
"dealerId" TEXT,
"passwordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_dealerId_fkey" FOREIGN KEY ("dealerId") REFERENCES "Dealer" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_User" ("createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt") SELECT "createdAt", "dealerId", "id", "name", "phone", "role", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+16 -2
View File
@@ -14,8 +14,7 @@ model User {
role String role String
dealerId String? dealerId String?
dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id]) dealer Dealer? @relation("DealerStaff", fields: [dealerId], references: [id])
currentOtp String? passwordHash String?
otpExpiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -24,6 +23,21 @@ model User {
transfers StockTransfer[] transfers StockTransfer[]
events Event[] events Event[]
servicePoints ServicePoint[] servicePoints ServicePoint[]
loginRequests LoginRequest[] @relation("DealerLoginRequests")
loginDecisions LoginRequest[] @relation("LoginDecisions")
}
model LoginRequest {
id String @id @default(cuid())
userId String
user User @relation("DealerLoginRequests", fields: [userId], references: [id], onDelete: Cascade)
status String
secretHash String
expiresAt DateTime
decidedById String?
decidedBy User? @relation("LoginDecisions", fields: [decidedById], references: [id])
decidedAt DateTime?
createdAt DateTime @default(now())
} }
model Dealer { model Dealer {
+9 -3
View File
@@ -3,24 +3,30 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const prisma = require('../src/db'); const prisma = require('../src/db');
const { isValidPhone, normalizePhone } = require('../src/lib/phone'); const { isValidPhone, normalizePhone } = require('../src/lib/phone');
const { hashPassword } = require('../src/lib/passwords');
async function main() { async function main() {
const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim(); const name = String(process.env.SEED_ADMIN_NAME || 'Super Admin').trim();
const phone = normalizePhone(process.env.SEED_ADMIN_PHONE); const phone = normalizePhone(process.env.SEED_ADMIN_PHONE);
const password = String(process.env.SEED_ADMIN_PASSWORD || '');
if (!isValidPhone(phone)) { if (!isValidPhone(phone)) {
throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number'); throw new Error('SEED_ADMIN_PHONE must be a Sri Lankan mobile number');
} }
if (password.length < 8) {
throw new Error('SEED_ADMIN_PASSWORD must be at least 8 characters');
}
const passwordHash = await hashPassword(password);
const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } }); const existingAdmin = await prisma.user.findFirst({ where: { role: 'superAdmin' } });
const admin = existingAdmin const admin = existingAdmin
? await prisma.user.update({ ? await prisma.user.update({
where: { id: existingAdmin.id }, where: { id: existingAdmin.id },
data: { name, phone, role: 'superAdmin', dealerId: null }, data: { name, phone, role: 'superAdmin', dealerId: null, passwordHash },
}) })
: await prisma.user.upsert({ : await prisma.user.upsert({
where: { phone }, where: { phone },
update: { name, role: 'superAdmin', dealerId: null }, update: { name, role: 'superAdmin', dealerId: null, passwordHash },
create: { name, phone, role: 'superAdmin' }, create: { name, phone, role: 'superAdmin', passwordHash },
}); });
console.log(`Seeded super admin ${admin.name} (${admin.phone})`); console.log(`Seeded super admin ${admin.name} (${admin.phone})`);
} }
+71 -25
View File
@@ -22,6 +22,7 @@ execSync('npx prisma db push --skip-generate --accept-data-loss', {
env: process.env, env: process.env,
}); });
const bcrypt = require('bcryptjs');
const app = require('../src/app'); const app = require('../src/app');
const prisma = require('../src/db'); const prisma = require('../src/db');
const { ageBadge } = require('../src/lib/stockRules'); const { ageBadge } = require('../src/lib/stockRules');
@@ -61,31 +62,30 @@ async function run(port) {
assert(ageBadge(45) === 'yellow', '45 days is yellow'); assert(ageBadge(45) === 'yellow', '45 days is yellow');
assert(ageBadge(46) === 'red', '46 days is red'); assert(ageBadge(46) === 'red', '46 days is red');
const unknown = await request(port, 'POST', '/api/auth/send-otp', { const unknown = await request(port, 'POST', '/api/auth/dealer-login', {
body: { phone: '94770000099' }, body: { phone: '94770000099' },
}); });
assert(unknown.status === 404, `unknown phone should be 404, got ${unknown.status}`); assert(unknown.status === 404, `unknown phone should be 404, got ${unknown.status}`);
const adminHash = await bcrypt.hash('admin-pass-123', 10);
await prisma.user.create({ await prisma.user.create({
data: { name: 'Super Admin', phone: '94770000001', role: 'superAdmin' }, data: {
name: 'Super Admin',
phone: '94770000001',
role: 'superAdmin',
passwordHash: adminHash,
},
}); });
const sent = await request(port, 'POST', '/api/auth/send-otp', { const badPassword = await request(port, 'POST', '/api/auth/staff-login', {
body: { phone: '94770000001' }, body: { phone: '94770000001', password: 'wrong-password' },
}); });
assert(sent.status === 200 && sent.json.success === true, 'OTP send failed'); assert(badPassword.status === 401, 'wrong password should be rejected');
const adminRow = await prisma.user.findUnique({ where: { phone: '94770000001' } }); const verified = await request(port, 'POST', '/api/auth/staff-login', {
const wrongCode = adminRow.currentOtp === '000000' ? '111111' : '000000'; body: { phone: '94770000001', password: 'admin-pass-123' },
const badOtp = await request(port, 'POST', '/api/auth/verify-otp', {
body: { phone: '94770000001', otp: wrongCode },
}); });
assert(badOtp.status === 401, 'wrong OTP should be rejected'); assert(verified.status === 200 && verified.json.token, 'staff login failed');
const verified = await request(port, 'POST', '/api/auth/verify-otp', {
body: { phone: '94770000001', otp: adminRow.currentOtp },
});
assert(verified.status === 200 && verified.json.token, 'OTP verify failed');
const adminToken = verified.json.token; const adminToken = verified.json.token;
const me = await request(port, 'GET', '/api/auth/me', { token: adminToken }); const me = await request(port, 'GET', '/api/auth/me', { token: adminToken });
@@ -112,6 +112,7 @@ async function run(port) {
body: { body: {
name: 'Nimal Perera', name: 'Nimal Perera',
phone: '94771111111', phone: '94771111111',
password: 'rep-pass-123',
dealerIds: [dealerA.json.data.id, dealerB.json.data.id], dealerIds: [dealerA.json.data.id, dealerB.json.data.id],
}, },
}); });
@@ -123,19 +124,64 @@ async function run(port) {
}); });
assert(dealerUser.status === 201, 'dealer user create failed'); assert(dealerUser.status === 201, 'dealer user create failed');
async function login(phone) { async function staffLogin(phone, password) {
const otpSend = await request(port, 'POST', '/api/auth/send-otp', { body: { phone } }); const result = await request(port, 'POST', '/api/auth/staff-login', {
assert(otpSend.status === 200, `otp send failed for ${phone}`); body: { phone, password },
const row = await prisma.user.findUnique({ where: { phone } });
const otpVerify = await request(port, 'POST', '/api/auth/verify-otp', {
body: { phone, otp: row.currentOtp },
}); });
assert(otpVerify.status === 200, `otp verify failed for ${phone}`); assert(result.status === 200 && result.json.token, `staff login failed for ${phone}`);
return otpVerify.json.token; return result.json.token;
} }
const repToken = await login('94771111111'); async function approveDealer(phone, token) {
const dealerToken = await login('94772222222'); const started = await request(port, 'POST', '/api/auth/dealer-login', { body: { phone } });
assert(started.status === 201, `dealer login failed for ${phone}: ${started.status}`);
const approved = await request(
port,
'POST',
`/api/auth/login-requests/${started.json.requestId}/approve`,
{ token }
);
assert(approved.status === 200, `approve failed for ${phone}: ${approved.status}`);
const ready = await request(
port,
'GET',
`/api/auth/dealer-login/${started.json.requestId}?secret=${encodeURIComponent(started.json.secret)}`
);
assert(ready.status === 200 && ready.json.token, `dealer token missing for ${phone}`);
return ready.json.token;
}
const repToken = await staffLogin('94771111111', 'rep-pass-123');
const dealerStaff = await request(port, 'POST', '/api/auth/staff-login', {
body: { phone: '94772222222', password: 'rep-pass-123' },
});
assert(dealerStaff.status === 401, 'dealer should not use staff login');
const otherDealer = await request(port, 'POST', '/api/admin/dealer-users', {
token: adminToken,
body: { name: 'Jaffna Desk', phone: '94773333333', dealerId: dealerC.json.data.id },
});
assert(otherDealer.status === 201, 'second dealer user create failed');
const outsideRequest = await request(port, 'POST', '/api/auth/dealer-login', {
body: { phone: '94773333333' },
});
assert(outsideRequest.status === 201, 'outside dealer login failed');
const repQueue = await request(port, 'GET', '/api/auth/login-requests', { token: repToken });
assert(
repQueue.status === 200 &&
!repQueue.json.data.some((item) => item.id === outsideRequest.json.requestId),
'sales rep should not see an unassigned dealer login'
);
const repDenied = await request(
port,
'POST',
`/api/auth/login-requests/${outsideRequest.json.requestId}/approve`,
{ token: repToken }
);
assert(repDenied.status === 403, 'sales rep should not approve an unassigned dealer');
const dealerToken = await approveDealer('94772222222', adminToken);
const forbiddenStock = await request(port, 'POST', '/api/stocks', { const forbiddenStock = await request(port, 'POST', '/api/stocks', {
token: dealerToken, token: dealerToken,
+14
View File
@@ -0,0 +1,14 @@
const bcrypt = require('bcryptjs');
const ROUNDS = 10;
function hashPassword(password) {
return bcrypt.hash(password, ROUNDS);
}
function checkPassword(password, passwordHash) {
if (!passwordHash) return false;
return bcrypt.compare(password, passwordHash);
}
module.exports = { hashPassword, checkPassword };
+7 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { isValidPhone, normalizePhone } = require('../lib/phone'); const { isValidPhone, normalizePhone } = require('../lib/phone');
const { hashPassword } = require('../lib/passwords');
const { canAccessDealer } = require('../lib/access'); const { canAccessDealer } = require('../lib/access');
const { userPublicSelect, publicUser } = require('../lib/users'); const { userPublicSelect, publicUser } = require('../lib/users');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth, requireRole } = require('../middleware/auth');
@@ -60,9 +61,13 @@ router.post(
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const name = String(req.body?.name || '').trim(); const name = String(req.body?.name || '').trim();
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
const password = String(req.body?.password || '');
const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : []; const dealerIds = Array.isArray(req.body?.dealerIds) ? req.body.dealerIds : [];
if (!name || !isValidPhone(phone)) { if (!name || !isValidPhone(phone)) {
throw httpError(400, 'Name and a 947XXXXXXXX phone are required'); throw httpError(400, 'Name and a Sri Lankan mobile number are required');
}
if (password.length < 8) {
throw httpError(400, 'Password must be at least 8 characters');
} }
if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) { if (!dealerIds.every((id) => typeof id === 'string' && id.trim())) {
throw httpError(400, 'dealerIds must be a list of ids'); throw httpError(400, 'dealerIds must be a list of ids');
@@ -78,6 +83,7 @@ router.post(
name, name,
phone, phone,
role: 'salesRep', role: 'salesRep',
passwordHash: await hashPassword(password),
assignments: { assignments: {
create: uniqueIds.map((dealerId) => ({ dealerId })), create: uniqueIds.map((dealerId) => ({ dealerId })),
}, },
+176 -61
View File
@@ -4,16 +4,18 @@ const jwt = require('jsonwebtoken');
const prisma = require('../db'); const prisma = require('../db');
const { asyncHandler, httpError } = require('../lib/errors'); const { asyncHandler, httpError } = require('../lib/errors');
const { isValidPhone, normalizePhone } = require('../lib/phone'); const { isValidPhone, normalizePhone } = require('../lib/phone');
const { checkPassword } = require('../lib/passwords');
const { publicUser, userPublicSelect } = require('../lib/users'); const { publicUser, userPublicSelect } = require('../lib/users');
const { assignedDealerIds } = require('../lib/access'); const { assignedDealerIds, canAccessDealer } = require('../lib/access');
const { sendSmsOtp } = require('../services/smsService'); const { requireAuth, requireRole } = require('../middleware/auth');
const { requireAuth } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
const OTP_TTL_MS = 5 * 60 * 1000; const REQUEST_TTL_MS = 5 * 60 * 1000;
function generateOtp() { function signToken(user) {
return String(crypto.randomInt(0, 1000000)).padStart(6, '0'); return jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '10y',
});
} }
async function accessibleDealers(user) { async function accessibleDealers(user) {
@@ -33,81 +35,194 @@ async function accessibleDealers(user) {
}); });
} }
router.post( function hashSecret(secret) {
'/send-otp', return crypto.createHash('sha256').update(secret).digest('hex');
asyncHandler(async (req, res) => { }
const phone = normalizePhone(req.body?.phone);
if (!isValidPhone(phone)) {
throw httpError(400, 'Phone must be in 947XXXXXXXX format');
}
const user = await prisma.user.findUnique({ where: { phone } }); function secretsMatch(storedHash, secret) {
if (!user) { const given = Buffer.from(hashSecret(secret));
return res.status(404).json({ success: false, message: 'Not Registered' }); const stored = Buffer.from(storedHash);
} return stored.length === given.length && crypto.timingSafeEqual(stored, given);
}
const otp = generateOtp(); async function expireStaleRequests() {
const otpExpiresAt = new Date(Date.now() + OTP_TTL_MS); await prisma.loginRequest.updateMany({
await prisma.user.update({ where: { status: 'pending', expiresAt: { lt: new Date() } },
where: { id: user.id }, data: { status: 'expired' },
data: { currentOtp: otp, otpExpiresAt },
}); });
}
try {
await sendSmsOtp(phone, otp);
} catch (error) {
await prisma.user.update({
where: { id: user.id },
data: { currentOtp: null, otpExpiresAt: null },
});
throw httpError(502, error.message || 'Failed to send OTP SMS');
}
return res.json({ success: true, message: 'OTP sent via SMS' });
})
);
router.post( router.post(
'/verify-otp', '/staff-login',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const phone = normalizePhone(req.body?.phone); const phone = normalizePhone(req.body?.phone);
const otp = String(req.body?.otp || '').trim(); const password = String(req.body?.password || '');
if (!isValidPhone(phone) || !/^\d{6}$/.test(otp)) { if (!isValidPhone(phone) || !password) {
throw httpError(400, 'Phone and a 6-digit OTP are required'); throw httpError(400, 'Phone and password are required');
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { phone }, where: { phone },
select: { select: { ...userPublicSelect, passwordHash: true },
...userPublicSelect, });
currentOtp: true, const allowed = user && (user.role === 'superAdmin' || user.role === 'salesRep');
otpExpiresAt: true, const matches = allowed && (await checkPassword(password, user.passwordHash));
if (!matches) {
throw httpError(401, 'Invalid phone or password');
}
const dealers = await accessibleDealers(user);
return res.json({ token: signToken(user), user: publicUser(user, dealers) });
})
);
router.post(
'/dealer-login',
asyncHandler(async (req, res) => {
const phone = normalizePhone(req.body?.phone);
if (!isValidPhone(phone)) {
throw httpError(400, 'Phone must be a Sri Lankan mobile number');
}
const user = await prisma.user.findUnique({ where: { phone } });
if (!user || user.role !== 'dealer') {
return res.status(404).json({ success: false, message: 'Not Registered' });
}
await prisma.loginRequest.updateMany({
where: { userId: user.id, status: 'pending' },
data: { status: 'expired' },
});
const secret = crypto.randomBytes(32).toString('hex');
const request = await prisma.loginRequest.create({
data: {
userId: user.id,
status: 'pending',
secretHash: hashSecret(secret),
expiresAt: new Date(Date.now() + REQUEST_TTL_MS),
}, },
}); });
if (!user || !user.currentOtp || !user.otpExpiresAt) {
throw httpError(401, 'Invalid OTP'); return res.status(201).json({ requestId: request.id, secret });
} })
if (user.otpExpiresAt.getTime() < Date.now()) { );
throw httpError(401, 'OTP expired');
router.get(
'/dealer-login/:id',
asyncHandler(async (req, res) => {
const secret = String(req.query.secret || '');
const request = await prisma.loginRequest.findUnique({
where: { id: req.params.id },
include: { user: { select: userPublicSelect } },
});
if (!request || !secret || !secretsMatch(request.secretHash, secret)) {
throw httpError(404, 'Login request not found');
} }
const stored = Buffer.from(user.currentOtp); if (request.status === 'pending' && request.expiresAt.getTime() < Date.now()) {
const given = Buffer.from(otp); await prisma.loginRequest.update({
const matches = stored.length === given.length && crypto.timingSafeEqual(stored, given); where: { id: request.id },
if (!matches) { data: { status: 'expired' },
throw httpError(401, 'Invalid OTP'); });
return res.json({ status: 'expired' });
} }
await prisma.user.update({ if (request.status !== 'approved') {
where: { id: user.id }, return res.json({ status: request.status });
data: { currentOtp: null, otpExpiresAt: null }, }
const consumed = await prisma.loginRequest.updateMany({
where: { id: request.id, status: 'approved' },
data: { status: 'consumed' },
});
if (consumed.count !== 1) {
return res.json({ status: 'consumed' });
}
const dealers = await accessibleDealers(request.user);
return res.json({
status: 'approved',
token: signToken(request.user),
user: publicUser(request.user, dealers),
});
})
);
router.get(
'/login-requests',
requireAuth,
requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => {
await expireStaleRequests();
const where = { status: 'pending' };
if (req.user.role === 'salesRep') {
const ids = assignedDealerIds(req.user);
where.user = { dealerId: { in: ids.length ? ids : ['__none__'] } };
}
const requests = await prisma.loginRequest.findMany({
where,
include: { user: { include: { dealer: true } } },
orderBy: { createdAt: 'asc' },
}); });
const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { return res.json({
expiresIn: '10y', success: true,
data: requests.map((request) => ({
id: request.id,
status: request.status,
createdAt: request.createdAt,
expiresAt: request.expiresAt,
user: {
id: request.user.id,
name: request.user.name,
phone: request.user.phone,
dealerId: request.user.dealerId,
},
dealerName: request.user.dealer ? request.user.dealer.name : null,
})),
}); });
const dealers = await accessibleDealers(user); })
return res.json({ token, user: publicUser(user, dealers) }); );
async function decideRequest(req, status) {
await expireStaleRequests();
const request = await prisma.loginRequest.findUnique({
where: { id: req.params.id },
include: { user: true },
});
if (!request || request.status !== 'pending') {
throw httpError(404, 'Login request not found');
}
if (!canAccessDealer(req.user, request.user.dealerId)) {
throw httpError(403, 'Forbidden');
}
await prisma.loginRequest.update({
where: { id: request.id },
data: { status, decidedById: req.user.id, decidedAt: new Date() },
});
return { id: request.id, status };
}
router.post(
'/login-requests/:id/approve',
requireAuth,
requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => {
const result = await decideRequest(req, 'approved');
return res.json({ success: true, data: result });
})
);
router.post(
'/login-requests/:id/reject',
requireAuth,
requireRole('superAdmin', 'salesRep'),
asyncHandler(async (req, res) => {
const result = await decideRequest(req, 'rejected');
return res.json({ success: true, data: result });
}) })
); );
-33
View File
@@ -1,33 +0,0 @@
const axios = require('axios');
const sendSmsOtp = async (phoneNumber, otpCode) => {
if (!process.env.TEXTLK_API_TOKEN) {
console.log(`DEV OTP for ${phoneNumber}: ${otpCode}`);
return { dev: true };
}
try {
const response = await axios.post(
'https://app.text.lk/api/v3/sms/send',
{
recipient: phoneNumber,
sender_id: process.env.TEXTLK_SENDER_ID || 'TextLKDemo',
type: 'plain',
message: `Your login OTP code is: ${otpCode}. Valid for 5 minutes.`,
},
{
headers: {
Authorization: `Bearer ${process.env.TEXTLK_API_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
return response.data;
} catch (error) {
console.error('Text.lk SMS Error:', error.response?.data || error.message);
throw new Error('Failed to send OTP SMS');
}
};
module.exports = { sendSmsOtp };