forked from Yara724/api
Compare commits
364 Commits
integrate-
...
2df25e26bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df25e26bb | |||
| c7fb6174a0 | |||
| 136b22fd81 | |||
| 16cdf2e7b0 | |||
| 488c9180af | |||
| 75c556a013 | |||
| 0f53bfabf5 | |||
| 8bf3cfe5e9 | |||
| f6ec7644ff | |||
| bc70a8c930 | |||
| 4caa958175 | |||
| 7d10c00ce5 | |||
| 114e5e6604 | |||
| f00cb226a7 | |||
|
|
e2b879d943 | ||
| d84bd24682 | |||
|
|
ed2b6948cf | ||
|
|
c6b417ced7 | ||
| 16d3c54613 | |||
|
|
db569db9d1 | ||
| 83e82ea68e | |||
|
|
8f29bb564c | ||
| 89e715b0c9 | |||
| 44f7ce5b54 | |||
| a2396da9e4 | |||
| 64f6245e06 | |||
| df79a4d307 | |||
| 65c30a6cba | |||
| 0dd6c8ff78 | |||
| 0442d04f20 | |||
|
|
d0e7694374 | ||
| 570fa865de | |||
|
|
8741d2ba82 | ||
| 7b53c98791 | |||
|
|
59eddb8e0e | ||
| 4e20fc5c96 | |||
|
|
4fabed77e5 | ||
|
|
2e4b10455b | ||
| 1bbf0de960 | |||
|
|
15fcb011aa | ||
| 2c52c14e03 | |||
|
|
5a89a0ff16 | ||
| d355771518 | |||
| f4301428c7 | |||
| e3406f7645 | |||
| 4d6183fa24 | |||
| ce4945ebb8 | |||
| 5cada3c6c8 | |||
| 761f0cb679 | |||
| 3c61e4397a | |||
| fae7e9b13b | |||
| 3c863bb90c | |||
| 0c5756d325 | |||
| 1670c9d145 | |||
| 92e05d2a49 | |||
| dcc3ee71de | |||
| fa188862e5 | |||
| d8f7766f10 | |||
| 084d0e1360 | |||
| 0111f3acd2 | |||
| 1ef17ce337 | |||
|
|
6df8044c5a | ||
|
|
bc5be99b59 | ||
|
|
a4eb98258b | ||
|
|
ad35d35065 | ||
|
|
4bd88ff0dd | ||
|
|
b008eda11b | ||
| 1680fdc5b4 | |||
|
|
921f9719c7 | ||
|
|
a7849f915a | ||
|
|
19dc2a76f2 | ||
|
|
41f81a2f76 | ||
| 048398d653 | |||
|
|
79905345e5 | ||
|
|
0ed7cd7012 | ||
| 1e7b0d7d06 | |||
|
|
6426233350 | ||
| 3e5e9852ad | |||
|
|
3d6b9e130c | ||
|
|
ec07d42ced | ||
| 407f58f5ee | |||
|
|
c8274d8435 | ||
| f0b24dcd26 | |||
|
|
5414d9717e | ||
|
|
e413991e7c | ||
| b144458943 | |||
|
|
3abbd45fac | ||
| cb47069e90 | |||
| 3c3b5191fb | |||
| 8053e1a088 | |||
|
|
d276c32e87 | ||
| 951ff9b50b | |||
|
|
a59e4c57a5 | ||
| 33c9811f61 | |||
|
|
cab584410f | ||
| c413bd3417 | |||
|
|
249c359898 | ||
| 393d43c4d2 | |||
|
|
34142942e5 | ||
|
|
f023d0f3e7 | ||
| 2d7afba75d | |||
|
|
9ee933cb76 | ||
|
|
16c598118d | ||
| 2b1edd64c1 | |||
|
|
d92231e517 | ||
| dc14698823 | |||
|
|
8236f0440d | ||
| ffcedcd5f1 | |||
|
|
bd5a33e2ba | ||
|
|
0b47e8789b | ||
|
|
2c810afcb6 | ||
|
|
077bae429e | ||
| 456135ad08 | |||
| eae46c3212 | |||
| fe82455562 | |||
| f2d7e39487 | |||
| 8730e9af62 | |||
| cf07122710 | |||
|
|
a0247d7769 | ||
| fcf3e63f9b | |||
| 519d91102f | |||
| 4bc5889ccd | |||
| a47c6f1c96 | |||
|
|
5fab0a00b6 | ||
| e650abdf40 | |||
|
|
6261af8a29 | ||
|
|
fde6464739 | ||
| a07b5c3c1e | |||
|
|
06af79fa47 | ||
| 1a8181a872 | |||
|
|
4a189ba4ef | ||
| 859244940c | |||
|
|
cec349b7c2 | ||
| 8d8f76eadd | |||
|
|
b9e7373225 | ||
| 6ddb06594b | |||
|
|
e90c6a5c50 | ||
| 97f26d400f | |||
|
|
02a69f3db2 | ||
| c137d6c6c4 | |||
|
|
2b7192151d | ||
| 389133e1c9 | |||
|
|
768d6d12fe | ||
|
|
84b752c6cc | ||
| 0622ceeaf4 | |||
|
|
3b0db0d250 | ||
| c502adbe76 | |||
|
|
7aada14551 | ||
| b3bf1b85f8 | |||
|
|
d6f1cb9eeb | ||
|
|
da298a3350 | ||
| 722cbbf5c9 | |||
|
|
245160bfc2 | ||
| 9c760d59f7 | |||
|
|
6ac0bf060e | ||
| 10df869efb | |||
| 5c372947dd | |||
|
|
9003a7abb6 | ||
| a994331439 | |||
|
|
ae12049e1b | ||
| 02031efdb5 | |||
|
|
48cc4d8a8d | ||
| cbbb45378d | |||
|
|
680f3c1798 | ||
|
|
64fa560f73 | ||
|
|
ff94fa35bf | ||
| 037d9fa934 | |||
|
|
2bdd0d507e | ||
| f60efa52b1 | |||
|
|
866696094f | ||
| ed936ad7b1 | |||
|
|
4d4106a8ab | ||
|
|
af875a4773 | ||
| 91221a6848 | |||
|
|
a31196774c | ||
| d2474d65bc | |||
|
|
94bde88cb6 | ||
| 39c4855b95 | |||
|
|
35487ad033 | ||
| 3dec22595c | |||
|
|
44723259d6 | ||
| c96e361990 | |||
|
|
e4dfe7c572 | ||
| 511d478064 | |||
|
|
cef684e37f | ||
| c81157c431 | |||
|
|
e1954cdb37 | ||
|
|
7ff3e9fd10 | ||
| ced8586710 | |||
|
|
f0ba8949cb | ||
| fb224360ab | |||
|
|
600c6bd7ed | ||
| 129be58cc9 | |||
|
|
094816ce8f | ||
| f2848a6179 | |||
|
|
7797a4ddab | ||
| 09eb6cc5c0 | |||
|
|
7c76149c95 | ||
| d562e09aab | |||
|
|
2c851725a5 | ||
| e4d6246103 | |||
|
|
b9d15d1ff6 | ||
| 241634b149 | |||
|
|
7ba3b57cee | ||
|
|
5b6409fc2e | ||
| fd4cd3128f | |||
| 0d0cec4b20 | |||
|
|
e26c533a52 | ||
|
|
f75e6a4453 | ||
|
|
e89cf107ff | ||
| 07c4e5126a | |||
|
|
010846acd9 | ||
|
|
cc926d4668 | ||
| fdb75d52f7 | |||
|
|
186f6c5837 | ||
|
|
3fb90cf1c9 | ||
| 5e5ad0e1b3 | |||
|
|
82bb232d28 | ||
| 45392ad268 | |||
|
|
9c62dc4d3a | ||
| d1bc64bb6d | |||
|
|
be58b7e47e | ||
| 78e86e5745 | |||
|
|
fe163419c0 | ||
|
|
eb648d8a87 | ||
| b856cb59f9 | |||
|
|
a52b7a0a72 | ||
|
|
7998649a89 | ||
|
|
74c91c73b6 | ||
|
|
9e2cec5bc3 | ||
| e95cb4c255 | |||
|
|
c1a54baaf0 | ||
| f8193b6622 | |||
| 3e889307a1 | |||
|
|
972b4b8719 | ||
| 96c21294db | |||
| c579f8fa1d | |||
| c2d59112cf | |||
|
|
e1115b0632 | ||
| 908292b0c3 | |||
| 55ec6f1fd1 | |||
| d33cff8438 | |||
| 0bb13f4596 | |||
| 70306d42e0 | |||
| d9dc4ecdff | |||
| f66fa5d7b4 | |||
| 6429cb0f2b | |||
| 6f120b0066 | |||
| 8419ec06ae | |||
| def4023185 | |||
| 40606fecf1 | |||
| c567f93e85 | |||
| a9846095c1 | |||
| b6f0e3c821 | |||
| bffb8a3b97 | |||
| 715a9f2467 | |||
| 3813c2b3e3 | |||
| ebf8a9a624 | |||
| 993d809de2 | |||
| 80ef885d3b | |||
|
|
3f608f63f1 | ||
| 6019c9e954 | |||
|
|
8ae6f2c91b | ||
|
|
f999313476 | ||
|
|
98f1d2caf5 | ||
|
|
bbd83da2d5 | ||
|
|
9296795166 | ||
|
|
f456443342 | ||
|
|
bcedd8c6f3 | ||
| 8caf13cf18 | |||
|
|
e90f8dc33c | ||
| 4c4b1a1db7 | |||
|
|
c2f996cc28 | ||
| 362e02ddc4 | |||
|
|
4b0e8a3547 | ||
|
|
885678df7d | ||
| 8dffc46357 | |||
|
|
b5b3b722c6 | ||
|
|
4f8cb43883 | ||
|
|
6c2d178686 | ||
| 9854a58282 | |||
|
|
7184142137 | ||
| d45975b6a7 | |||
|
|
05c5b70b4d | ||
|
|
5d2227b00b | ||
| eef305c42e | |||
|
|
9c87927e6c | ||
| 5618d120e3 | |||
|
|
d9b1537ee4 | ||
| f01882dc3a | |||
|
|
8284aba825 | ||
| 3c15901ecc | |||
|
|
1e1edf9136 | ||
| daad9539ea | |||
|
|
194b71cdb2 | ||
|
|
04ba46ed2a | ||
| c6f7edabd2 | |||
| 75b7e5ecad | |||
| a3156881b8 | |||
| 41c44dcf23 | |||
|
|
95bfc095ce | ||
| b63c2155bc | |||
| 9463142ecf | |||
| a71c4ea980 | |||
|
|
b826a133a4 | ||
| b4501c503e | |||
|
|
a0d337aff0 | ||
| 28e2de459a | |||
| af1a07743e | |||
| fca88bc151 | |||
|
|
e264695db3 | ||
| 277f2d4d66 | |||
|
|
514018beb1 | ||
| 374b15933d | |||
| 08a4d754c1 | |||
| c5b2d7b520 | |||
|
|
5a84080bf1 | ||
| 68b3b2000b | |||
|
|
028ca899fe | ||
|
|
0086b8db4d | ||
|
|
7f5b64f2a6 | ||
| 2898897dd9 | |||
| e8a3873851 | |||
| 131df63a3f | |||
| b9eb0ab5bd | |||
| 4bdb9fd469 | |||
| 38f700c3d7 | |||
|
|
1a1d55bc2e | ||
| a2f1d5ea7f | |||
|
|
9a65071276 | ||
| 12d6fa4d73 | |||
|
|
640b240ada | ||
|
|
f8fbbb7ac6 | ||
|
|
0e2789c209 | ||
| 7661862564 | |||
|
|
a2143fe1c5 | ||
|
|
494e3d93ab | ||
| 9afb079786 | |||
| 25958bb966 | |||
|
|
1d51370b3e | ||
| e44721b7be | |||
| 7ef057dc1f | |||
| 1b68a81204 | |||
| f77da50d1f | |||
| 79c2982e19 | |||
| fb4d166c31 | |||
| 9084f8fafb | |||
| b216a363fb | |||
|
|
b2391751f0 | ||
| df6d2bd04d | |||
| f277d87dfa | |||
| 672ec25438 | |||
| aa871c86f6 | |||
| 553a34054c | |||
| 8af152abc0 | |||
| 0446c83b36 | |||
| 255cfd3eb3 | |||
| b1be5b1e09 | |||
| 7d3565ec51 | |||
| fc599acf4a | |||
|
|
b40270f058 | ||
|
|
8d6fa3daac | ||
| 64dfd1ca8a | |||
| 665ed9e70f |
78
.env.example
Normal file
78
.env.example
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ---------------------------------------------
|
||||||
|
# 🔧 Application Environment
|
||||||
|
# ---------------------------------------------
|
||||||
|
NODE_ENV =
|
||||||
|
PORT =
|
||||||
|
CLIENT_ID =
|
||||||
|
CLIENT_NAME =
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🌐 Application URLs
|
||||||
|
# ---------------------------------------------
|
||||||
|
URL =
|
||||||
|
USER_BASE_PATH =
|
||||||
|
BASE_URL_DEV =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 📄 Swagger / API Documentation
|
||||||
|
# ---------------------------------------------
|
||||||
|
SWAGGER_USER_DEV =
|
||||||
|
SWAGGER_PASSWORD_DEV =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🗄️ Database (MongoDB)
|
||||||
|
# ---------------------------------------------
|
||||||
|
MONGO_HOST =
|
||||||
|
MONGO_PORT =
|
||||||
|
MONGO_USER =
|
||||||
|
MONGO_PASS =
|
||||||
|
MONGO_DB_NAME =
|
||||||
|
MONGO_OPTIONS =
|
||||||
|
MONGO_TLS =
|
||||||
|
MONGO_TLS_ALLOW_INVALID_CERTS =
|
||||||
|
MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB_NAME}?${MONGO_OPTIONS}'
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🔐 Authentication / Security
|
||||||
|
# ---------------------------------------------
|
||||||
|
JWT_SECRET =
|
||||||
|
JWT_EXPIRY =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🧩 SanHub Microservice
|
||||||
|
# ---------------------------------------------
|
||||||
|
SANHUB_BASE_URL =
|
||||||
|
SANHUB_URL_LOGIN =
|
||||||
|
SANHUB_USERNAME =
|
||||||
|
SANHUB_PASSWORD =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🤖 AI Services
|
||||||
|
# ---------------------------------------------
|
||||||
|
AI_URL =
|
||||||
|
AI_URL_V2 =
|
||||||
|
AI_USERNAME =
|
||||||
|
AI_PASSWORD =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 📩 SMS
|
||||||
|
# ---------------------------------------------
|
||||||
|
SMS_PROVIDER =
|
||||||
|
SMS_API_KEY =
|
||||||
|
AUTH_SMS_TEMPLATE =
|
||||||
|
EXP_OTP_TIME =
|
||||||
|
FAKE_OTP =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# ⚙️ Application Features / Flags
|
||||||
|
# ---------------------------------------------
|
||||||
|
AUTO_CLIENT_KEY_ENABLED =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🔗 Other Internal Services
|
||||||
|
# ---------------------------------------------
|
||||||
|
TEJARAT_INQUIRY_EMAIL =
|
||||||
|
TEJARAT_INQUIRY_PASSWORD =
|
||||||
|
|
||||||
|
PARSIAN_API_KEY =
|
||||||
|
PARSIAN_BASIC_TOKEN =
|
||||||
|
PARSIAN_SMS_URL =
|
||||||
10
LICENSE
10
LICENSE
@@ -1,10 +0,0 @@
|
|||||||
This is free and unencumbered software released into the public domain.
|
|
||||||
|
|
||||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
|
|
||||||
|
|
||||||
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
|
|
||||||
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
For more information, please refer to <http://unlicense.org/>
|
|
||||||
13
README.md
13
README.md
@@ -1,2 +1,15 @@
|
|||||||
# api
|
# api
|
||||||
|
|
||||||
|
Current JWT payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "saman_insurer@gmail.com",
|
||||||
|
"sub": "6a144979799f3c63aa63f67c",
|
||||||
|
"fullName": "بیمه گر سامان",
|
||||||
|
"role": "company",
|
||||||
|
"userType": "UserType",
|
||||||
|
"clientKey": "67f0fd0e53868dc1ff8a2738",
|
||||||
|
"iat": 1781339791,
|
||||||
|
"exp": 1781343391
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
1
err.json
1
err.json
@@ -1 +0,0 @@
|
|||||||
{"1000":{"info":"start","message":"start"},"1001":{"info":"Access Denied Other Actor Lock File","message":""},"1004":{"info":"g","message":""},"1005":{"info":"fSF","message":""},"1006":{"info":"request not found ","message":""}}
|
|
||||||
17205
package-lock.json
generated
17205
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
99
package.json
99
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yara724",
|
"name": "yara724",
|
||||||
"version": "0.0.1",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -12,82 +12,66 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=10.0.0",
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arashioz/errjson-talieh": "^2.2.5",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@fraybabak/kavenegar_nest": "^1.0.5",
|
"@nestjs/common": "^11.0.17",
|
||||||
"@nestjs-modules/mailer": "^1.8.1",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/axios": "^3.1.3",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/platform-express": "^11.1.11",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/serve-static": "^5.0.5",
|
||||||
"@nestjs/mongoose": "^10.1.0",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"axios": "^1.16.1",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
|
||||||
"@nestjs/platform-fastify": "^10.4.15",
|
|
||||||
"@nestjs/platform-socket.io": "^10.4.15",
|
|
||||||
"@nestjs/schedule": "^4.1.2",
|
|
||||||
"@nestjs/serve-static": "^4.0.2",
|
|
||||||
"@nestjs/swagger": "^7.4.2",
|
|
||||||
"@nestjs/websockets": "^10.4.15",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"axios": "^1.9.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.15.1",
|
||||||
"crypto": "^1.0.1",
|
"express": "^5.2.1",
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"express-basic-auth": "^1.2.1",
|
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"form-data": "^4.0.2",
|
"joi": "^18.2.1",
|
||||||
"jalali-moment": "^3.3.11",
|
|
||||||
"kavenegar": "^1.1.4",
|
|
||||||
"mongoose": "^8.9.2",
|
"mongoose": "^8.9.2",
|
||||||
"nestjs-command": "^3.1.4",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"short-unique-id": "^5.2.0",
|
"svg-captcha": "^1.4.0"
|
||||||
"standard": "^17.1.2",
|
|
||||||
"standardjs": "^1.0.0-alpha",
|
|
||||||
"uuid": "^11.0.3",
|
|
||||||
"yargs": "^17.7.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.14",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@nestjs/schematics": "^10.2.3",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@swc/cli": "^0.8.1",
|
||||||
|
"@swc/core": "^1.10.8",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.7",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/yargs": "^17.0.33",
|
"eslint": "^9.18.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"@typescript-eslint/parser": "^8.18.1",
|
"eslint-plugin-prettier": "^5.2.3",
|
||||||
"eslint": "^9.17.0",
|
"globals": "^15.14.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"ts-standard": "^12.0.2",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
@@ -100,10 +84,19 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@nestjs/core",
|
||||||
|
"@swc/core"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
scripts/data/parsian-tehran/branches.json
Normal file
185
scripts/data/parsian-tehran/branches.json
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
{
|
||||||
|
"clientCode": 8,
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"code": "100100",
|
||||||
|
"name": "واحدصدورالکترونيکي",
|
||||||
|
"fullName": "واحدصدورالکترونيکي(100100)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
|
||||||
|
"phoneNumber": "8259",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "110011",
|
||||||
|
"name": "ستاد مرکزي",
|
||||||
|
"fullName": "ستاد مرکزي(110011)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||||
|
"phoneNumber": "8259",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "111130",
|
||||||
|
"name": "شعبه ويژه ميرداماد",
|
||||||
|
"fullName": "شعبه ويژه ميرداماد(111130)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||||
|
"phoneNumber": "8259",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "120021",
|
||||||
|
"name": "سرپرستي منطقه يک كشور",
|
||||||
|
"fullName": "سرپرستي منطقه يک كشور(120021)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
|
||||||
|
"phoneNumber": "0218259",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "130031",
|
||||||
|
"name": "سرپرستي منطقه مركزي كشور",
|
||||||
|
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
|
||||||
|
"phoneNumber": "03133328257",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "140041",
|
||||||
|
"name": "سرپرستي منطقه شمالغرب کشور",
|
||||||
|
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
|
||||||
|
"phoneNumber": "04133832289",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "150051",
|
||||||
|
"name": "سرپرستي منطقه جنوب كشور",
|
||||||
|
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
|
||||||
|
"phoneNumber": "01738315473",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "150053",
|
||||||
|
"name": "سرپرست منطقه جنوب شرقي کشور",
|
||||||
|
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
|
||||||
|
"city": "کرمان",
|
||||||
|
"state": "کرمان",
|
||||||
|
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
|
||||||
|
"phoneNumber": "03432718000",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "160061",
|
||||||
|
"name": "سرپرستي منطقه شرق كشور",
|
||||||
|
"fullName": "سرپرستي منطقه شرق كشور(160061)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
|
||||||
|
"phoneNumber": "05137659005",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "170071",
|
||||||
|
"name": "سرپرستي منطقه غرب كشور",
|
||||||
|
"fullName": "سرپرستي منطقه غرب كشور(170071)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
|
||||||
|
"phoneNumber": "08338431017",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "180081",
|
||||||
|
"name": "سرپرستي منطقه شمال شرق کشور",
|
||||||
|
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
|
||||||
|
"city": "ساري",
|
||||||
|
"state": "مازندران",
|
||||||
|
"address": "ساري، شعبه ساري",
|
||||||
|
"phoneNumber": "01133207241",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "180083",
|
||||||
|
"name": "سرپرست منطقه شمال کشوري",
|
||||||
|
"fullName": "سرپرست منطقه شمال کشوري(180083)",
|
||||||
|
"city": "رشت",
|
||||||
|
"state": "گيلان",
|
||||||
|
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
|
||||||
|
"phoneNumber": "01333512135",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "190092",
|
||||||
|
"name": "سرپرستي جنوب غربي کشور",
|
||||||
|
"fullName": "سرپرستي جنوب غربي کشور(190092)",
|
||||||
|
"city": "اهواز",
|
||||||
|
"state": "خوزستان",
|
||||||
|
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
|
||||||
|
"phoneNumber": "06133743877",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "210040",
|
||||||
|
"name": "شعبه شرق تهران",
|
||||||
|
"fullName": "شعبه شرق تهران(210040)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
|
||||||
|
"phoneNumber": "77393783-4",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "210050",
|
||||||
|
"name": "شعبه غرب تهران",
|
||||||
|
"fullName": "شعبه غرب تهران(210050)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران ـخيابان آزادي ( محله تيموري )، نبش خيابان شهيد داود حبيب زادگان پلاک 2 - 1458887853",
|
||||||
|
"phoneNumber": "66021968",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "210110",
|
||||||
|
"name": "شعبه پونک",
|
||||||
|
"fullName": "شعبه پونک(210110)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، خيابان ميرزا بابايي، نبش خيابان سردارجنگل، پارك سوارپونك",
|
||||||
|
"phoneNumber": "44452270",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "210120",
|
||||||
|
"name": "شعبه والفجر",
|
||||||
|
"fullName": "شعبه والفجر(210120)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
|
||||||
|
"phoneNumber": "86051332",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "210150",
|
||||||
|
"name": "شعبه شمال شرق تهران",
|
||||||
|
"fullName": "شعبه شمال شرق تهران(210150)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
|
||||||
|
"phoneNumber": "26244319",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
153
scripts/data/parsian-tehran/field-experts.json
Normal file
153
scripts/data/parsian-tehran/field-experts.json
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"clientCode": 8,
|
||||||
|
"fieldExperts": [
|
||||||
|
{
|
||||||
|
"nationalCode": "0013480261",
|
||||||
|
"firstName": "عليرضا",
|
||||||
|
"lastName": "خازني",
|
||||||
|
"branchCode": "210040",
|
||||||
|
"branchName": "شعبه شرق تهران(210040)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0051967839",
|
||||||
|
"mobile": "09121354859",
|
||||||
|
"firstName": "حسين",
|
||||||
|
"lastName": "جعفري",
|
||||||
|
"branchCode": "210120",
|
||||||
|
"branchName": "شعبه والفجر",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0056888082",
|
||||||
|
"mobile": "09122406750",
|
||||||
|
"firstName": "قاسم",
|
||||||
|
"lastName": "نصراللهي",
|
||||||
|
"branchCode": "210050",
|
||||||
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4664"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0066868521",
|
||||||
|
"mobile": "09129344240",
|
||||||
|
"firstName": "عليرضا",
|
||||||
|
"lastName": "گودرزي پور",
|
||||||
|
"branchCode": "210050",
|
||||||
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه",
|
||||||
|
"expertCode": "4663"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0076988961",
|
||||||
|
"mobile": "09108357378",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "روشن دل",
|
||||||
|
"branchCode": "210110",
|
||||||
|
"branchName": "شعبه پونک",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0078209129",
|
||||||
|
"mobile": "09126038117",
|
||||||
|
"firstName": "مهدي",
|
||||||
|
"lastName": "شاملوفرد",
|
||||||
|
"branchCode": "210050",
|
||||||
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4666"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0083730397",
|
||||||
|
"mobile": "09125759960",
|
||||||
|
"firstName": "مجيد",
|
||||||
|
"lastName": "کاظمي دولت سرا",
|
||||||
|
"branchCode": "210120",
|
||||||
|
"branchName": "شعبه والفجر",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0084130938",
|
||||||
|
"mobile": "09392558640",
|
||||||
|
"firstName": "رسول",
|
||||||
|
"lastName": "کرکي",
|
||||||
|
"branchCode": "210050",
|
||||||
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4662"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0440245151",
|
||||||
|
"mobile": "09130606183",
|
||||||
|
"firstName": "فرهاد",
|
||||||
|
"lastName": "ملکي مونقي",
|
||||||
|
"branchCode": "110011",
|
||||||
|
"branchName": "ستاد مرکزي",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0493217789",
|
||||||
|
"mobile": "09126966943",
|
||||||
|
"firstName": "مصطفي",
|
||||||
|
"lastName": "محمدزاده قورقچي",
|
||||||
|
"branchCode": "210050",
|
||||||
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4665"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0670358118",
|
||||||
|
"mobile": "09124421539",
|
||||||
|
"firstName": "مجيد",
|
||||||
|
"lastName": "اميري",
|
||||||
|
"branchCode": "210040",
|
||||||
|
"branchName": "شعبه شرق تهران(210040)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "0759153981",
|
||||||
|
"firstName": "رضا",
|
||||||
|
"lastName": "صالحي زاده",
|
||||||
|
"branchCode": "210040",
|
||||||
|
"branchName": "شعبه شرق تهران(210040)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "1262982308",
|
||||||
|
"mobile": "09130121246",
|
||||||
|
"firstName": "روح الله",
|
||||||
|
"lastName": "سلمانيان مقدم نياسري",
|
||||||
|
"branchCode": "210120",
|
||||||
|
"branchName": "شعبه والفجر",
|
||||||
|
"city": "کاشان",
|
||||||
|
"state": "اصفهان",
|
||||||
|
"title": "كارشناس ارزياب خسارت بدنه"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nationalCode": "3781847039",
|
||||||
|
"firstName": "اکبر",
|
||||||
|
"lastName": "ديني",
|
||||||
|
"branchCode": "210040",
|
||||||
|
"branchName": "شعبه شرق تهران(210040)",
|
||||||
|
"city": "تهران",
|
||||||
|
"state": "تهران",
|
||||||
|
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
920
scripts/refresh-blame-inquiries.js
Normal file
920
scripts/refresh-blame-inquiries.js
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One-time script to replace mocked blameCases party vehicle inquiry data.
|
||||||
|
* THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both
|
||||||
|
* Tejarat third-party block inquiry and car-body inquiry. All cases also
|
||||||
|
* refresh available party personal inquiries into inquiries.person.
|
||||||
|
*
|
||||||
|
* Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const loadedEnvFiles = loadEnvFiles(process.env.ENV_FILE || ".env");
|
||||||
|
|
||||||
|
const LETTER_TO_NUMBER = {
|
||||||
|
"الف": 1,
|
||||||
|
"ب": 2,
|
||||||
|
"ت": 3,
|
||||||
|
"ج": 4,
|
||||||
|
"د": 5,
|
||||||
|
"س": 6,
|
||||||
|
"ص": 7,
|
||||||
|
"ط": 8,
|
||||||
|
"ع": 9,
|
||||||
|
"ق": 10,
|
||||||
|
"ل": 11,
|
||||||
|
"م": 12,
|
||||||
|
"ن": 13,
|
||||||
|
"و": 14,
|
||||||
|
"ه": 15,
|
||||||
|
"ی": 16,
|
||||||
|
"ر": 17,
|
||||||
|
"ک": 18,
|
||||||
|
"ژ": 19,
|
||||||
|
"پ": 20,
|
||||||
|
"ظ": 24,
|
||||||
|
"ض": 25,
|
||||||
|
"ز": 41,
|
||||||
|
"ش": 42,
|
||||||
|
"گ": 43,
|
||||||
|
"ث": 44,
|
||||||
|
D: 45,
|
||||||
|
S: 46,
|
||||||
|
"ح": 47,
|
||||||
|
"ف": 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMBER_TO_LETTER = Object.fromEntries(
|
||||||
|
Object.entries(LETTER_TO_NUMBER).map(([letter, number]) => [String(number), letter]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
mongoUri: requiredEnv("MONGO_URL", "MONGODB_URI", "DATABASE_URL"),
|
||||||
|
collectionName: process.env.BLAME_COLLECTION || "blameCases",
|
||||||
|
mongoDbName: process.env.MONGO_DB_NAME || "",
|
||||||
|
thirdPartyUrl:
|
||||||
|
process.env.TEJARAT_THIRD_PARTY_URL ||
|
||||||
|
"http://82.99.202.245:3027/block-inquiry-tejarat",
|
||||||
|
carBodyUrl:
|
||||||
|
process.env.TEJARAT_CAR_BODY_URL ||
|
||||||
|
"http://82.99.202.245:3027/block-inquiry-tejarat/badane",
|
||||||
|
personUrl:
|
||||||
|
process.env.TEJARAT_PERSON_URL ||
|
||||||
|
"http://82.99.202.245:3027/personal-inquiry/tejarat-no",
|
||||||
|
thirdPartyToken:
|
||||||
|
process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||||
|
carBodyToken:
|
||||||
|
process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||||
|
personToken:
|
||||||
|
process.env.TEJARAT_PERSON_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||||
|
rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5),
|
||||||
|
retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false",
|
||||||
|
retryCount: Number(process.env.RETRY_COUNT || 3),
|
||||||
|
retryDelayMs: Number(process.env.RETRY_DELAY_MS || 2000),
|
||||||
|
dryRun: String(process.env.DRY_RUN ?? "true").toLowerCase() !== "false",
|
||||||
|
limit: process.env.LIMIT ? Number(process.env.LIMIT) : 0,
|
||||||
|
publicId: process.env.PUBLIC_ID || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastRequestAt = 0;
|
||||||
|
|
||||||
|
main().catch(async (error) => {
|
||||||
|
console.error("[fatal]", error && error.stack ? error.stack : error);
|
||||||
|
await mongoose.disconnect().catch(() => undefined);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
validateConfig();
|
||||||
|
|
||||||
|
console.log("script runned successfully");
|
||||||
|
console.log(
|
||||||
|
"[config] envFiles=" +
|
||||||
|
(loadedEnvFiles.length ? loadedEnvFiles.join(",") : "none") +
|
||||||
|
", dbName=" +
|
||||||
|
(config.mongoDbName || "from-url-or-driver-default") +
|
||||||
|
", collection=" +
|
||||||
|
config.collectionName +
|
||||||
|
", dryRun=" +
|
||||||
|
config.dryRun +
|
||||||
|
", rateLimitPerMinute=" +
|
||||||
|
config.rateLimitPerMinute +
|
||||||
|
", retryEnabled=" +
|
||||||
|
config.retryEnabled +
|
||||||
|
", retryCount=" +
|
||||||
|
config.retryCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
await mongoose.connect(config.mongoUri, {
|
||||||
|
autoIndex: false,
|
||||||
|
dbName: config.mongoDbName || undefined,
|
||||||
|
});
|
||||||
|
const collection = mongoose.connection.collection(config.collectionName);
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
type: { $in: ["THIRD_PARTY", "CAR_BODY"] },
|
||||||
|
};
|
||||||
|
if (config.publicId) query.publicId = config.publicId;
|
||||||
|
|
||||||
|
const totalDocs = await collection.countDocuments(query);
|
||||||
|
console.log(`total docs that we have to edit: ${totalDocs}`);
|
||||||
|
|
||||||
|
const cursor = collection
|
||||||
|
.find(query, {
|
||||||
|
projection: {
|
||||||
|
publicId: 1,
|
||||||
|
requestNo: 1,
|
||||||
|
type: 1,
|
||||||
|
parties: 1,
|
||||||
|
inquiries: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.sort({ createdAt: 1, _id: 1 });
|
||||||
|
|
||||||
|
if (config.limit > 0) cursor.limit(config.limit);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
docsSeen: 0,
|
||||||
|
docsChanged: 0,
|
||||||
|
partiesInquired: 0,
|
||||||
|
partiesSkipped: 0,
|
||||||
|
partiesFailed: 0,
|
||||||
|
personInquired: 0,
|
||||||
|
personSkipped: 0,
|
||||||
|
personFailed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const doc of cursor) {
|
||||||
|
summary.docsSeen += 1;
|
||||||
|
const label = doc.publicId || doc.requestNo || String(doc._id);
|
||||||
|
const requestId = String(doc._id);
|
||||||
|
console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`);
|
||||||
|
|
||||||
|
const parties = Array.isArray(doc.parties) ? clone(doc.parties) : [];
|
||||||
|
const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? clone(doc.inquiries) : {};
|
||||||
|
let docChanged = false;
|
||||||
|
let inquiriesChanged = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < parties.length; index += 1) {
|
||||||
|
const party = parties[index];
|
||||||
|
const partyLabel = `${label} parties[${index}] role=${party && party.role ? party.role : "-"}`;
|
||||||
|
|
||||||
|
const input = buildInquiryInputs(doc, party);
|
||||||
|
if (!input.ok) {
|
||||||
|
summary.partiesSkipped += 1;
|
||||||
|
console.warn(`[skip] ${partyLabel}: ${input.reason}`);
|
||||||
|
} else {
|
||||||
|
let nextParty = party;
|
||||||
|
let partyChanged = false;
|
||||||
|
|
||||||
|
for (const request of input.requests) {
|
||||||
|
console.log(
|
||||||
|
`[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inquiryResponse = await inquiryWithRetry(request.type, request.body);
|
||||||
|
const successful = isInquirySuccessful(request.type, inquiryResponse);
|
||||||
|
console.log(
|
||||||
|
`[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!successful) {
|
||||||
|
summary.partiesFailed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate);
|
||||||
|
summary.partiesInquired += 1;
|
||||||
|
partyChanged = true;
|
||||||
|
} catch (error) {
|
||||||
|
summary.partiesFailed += 1;
|
||||||
|
console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partyChanged) {
|
||||||
|
parties[index] = nextParty;
|
||||||
|
docChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const personInput = buildPersonInquiryInput(party);
|
||||||
|
if (!personInput.ok) {
|
||||||
|
summary.personSkipped += 1;
|
||||||
|
console.warn(`[skip] ${partyLabel} person: ${personInput.reason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[request] ${partyLabel} type=PERSON body=${JSON.stringify(personInput.body)}`);
|
||||||
|
try {
|
||||||
|
const personResponse = await inquiryWithRetry("PERSON", personInput.body);
|
||||||
|
const successful = isInquirySuccessful("PERSON", personResponse);
|
||||||
|
console.log(
|
||||||
|
`[response] ${partyLabel} type=PERSON successful=${successful} body=${JSON.stringify(personResponse)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!successful) {
|
||||||
|
summary.personFailed += 1;
|
||||||
|
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, personResponse);
|
||||||
|
inquiriesChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPersonInquiryToCaseInquiries(
|
||||||
|
inquiries,
|
||||||
|
party,
|
||||||
|
index,
|
||||||
|
true,
|
||||||
|
normalizePersonResponse(personResponse),
|
||||||
|
);
|
||||||
|
summary.personInquired += 1;
|
||||||
|
inquiriesChanged = true;
|
||||||
|
} catch (error) {
|
||||||
|
summary.personFailed += 1;
|
||||||
|
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, error);
|
||||||
|
inquiriesChanged = true;
|
||||||
|
console.error(`[error] ${partyLabel} type=PERSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docChanged && !inquiriesChanged) {
|
||||||
|
console.log(`[doc] ${label} no changes`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.dryRun) {
|
||||||
|
console.log(`[dry-run] ${label} would update parties/inquiries`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSet = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (docChanged) updateSet.parties = parties;
|
||||||
|
if (inquiriesChanged) updateSet.inquiries = inquiries;
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ _id: doc._id },
|
||||||
|
{
|
||||||
|
$set: updateSet,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
summary.docsChanged += result.modifiedCount;
|
||||||
|
console.log(`[update] ${label} matched=${result.matchedCount} modified=${result.modifiedCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongoose.disconnect();
|
||||||
|
console.log(`[done] ${JSON.stringify(summary)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInquiryInputs(doc, party) {
|
||||||
|
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
|
||||||
|
if (!party.vehicle || typeof party.vehicle !== "object") {
|
||||||
|
return { ok: false, reason: "party.vehicle is missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plate = extractPlate(party);
|
||||||
|
const nationalCode =
|
||||||
|
cleanString(party.person && party.person.nationalCodeOfInsurer) ||
|
||||||
|
cleanString(party.person && party.person.nationalCodeOfDriver);
|
||||||
|
|
||||||
|
if (!plate) return { ok: false, reason: "Plk1/Plk2/Plk3/PlkSrl not found" };
|
||||||
|
if (!nationalCode) return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
|
||||||
|
|
||||||
|
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
|
||||||
|
if (!serialLetter) return { ok: false, reason: `no Persian letter mapping for Plk2=${plate.Plk2}` };
|
||||||
|
|
||||||
|
if (doc.type === "THIRD_PARTY") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
plate,
|
||||||
|
requests: [buildThirdPartyRequest(plate, serialLetter, nationalCode)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.type === "CAR_BODY") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
plate,
|
||||||
|
requests: [
|
||||||
|
buildThirdPartyRequest(plate, serialLetter, nationalCode),
|
||||||
|
buildCarBodyRequest(plate, serialLetter, nationalCode),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: `unsupported type=${doc.type}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThirdPartyRequest(plate, serialLetter, nationalCode) {
|
||||||
|
return {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
body: {
|
||||||
|
leftTwoDigits: String(plate.Plk1),
|
||||||
|
serialLetter,
|
||||||
|
threeDigits: String(plate.Plk3),
|
||||||
|
rightTwoDigits: String(plate.PlkSrl),
|
||||||
|
nationalCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCarBodyRequest(plate, serialLetter, nationalCode) {
|
||||||
|
return {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
body: {
|
||||||
|
part1: toNumber(plate.Plk1),
|
||||||
|
part2: serialLetter,
|
||||||
|
part3: toNumber(plate.Plk3),
|
||||||
|
part4: toNumber(plate.PlkSrl),
|
||||||
|
nationalCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPersonInquiryInput(party) {
|
||||||
|
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
|
||||||
|
const person = party.person && typeof party.person === "object" ? party.person : null;
|
||||||
|
if (!person) return { ok: false, reason: "party.person is missing" };
|
||||||
|
|
||||||
|
const nationalCode =
|
||||||
|
cleanString(person.nationalCodeOfInsurer) ||
|
||||||
|
cleanString(person.nationalCodeOfDriver);
|
||||||
|
const birthDate = firstPresent(
|
||||||
|
person.insurerBirthday,
|
||||||
|
person.driverBirthday,
|
||||||
|
person.birthday,
|
||||||
|
);
|
||||||
|
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||||
|
|
||||||
|
if (!nationalCode) {
|
||||||
|
return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
|
||||||
|
}
|
||||||
|
if (!gregorianBirthdate) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `invalid insurerBirthday/driverBirthday=${cleanString(birthDate)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body: {
|
||||||
|
nationalCode,
|
||||||
|
birthdate: gregorianBirthdate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlateId(plateId) {
|
||||||
|
const value = cleanString(plateId);
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const parts = value.split("-").map((part) => normalizePlateNumber(part));
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
|
const thirdPartIsLetter = LETTER_TO_NUMBER[parts[2]] !== undefined;
|
||||||
|
const fourthPartIsLetter = LETTER_TO_NUMBER[parts[3]] !== undefined;
|
||||||
|
|
||||||
|
if (thirdPartIsLetter) {
|
||||||
|
return {
|
||||||
|
Plk1: parts[1],
|
||||||
|
Plk2: String(LETTER_TO_NUMBER[parts[2]]),
|
||||||
|
Plk3: parts[3],
|
||||||
|
PlkSrl: parts[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fourthPartIsLetter) {
|
||||||
|
return {
|
||||||
|
Plk1: parts[2],
|
||||||
|
Plk2: String(LETTER_TO_NUMBER[parts[3]]),
|
||||||
|
Plk3: parts[1],
|
||||||
|
PlkSrl: parts[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlate(party) {
|
||||||
|
const plateFromPlateId = parsePlateId(party.vehicle && party.vehicle.plateId);
|
||||||
|
if (plateFromPlateId) return plateFromPlateId;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.mapped,
|
||||||
|
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.raw,
|
||||||
|
party.vehicle && party.vehicle.inquiry,
|
||||||
|
party.vehicle,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const Plk1 = firstPresent(candidate.Plk1, candidate.platePartOne);
|
||||||
|
const Plk2 = firstPresent(candidate.Plk2, candidate.plateLetterid, candidate.plateLetterId);
|
||||||
|
const Plk3 = firstPresent(candidate.Plk3, candidate.platePartThree);
|
||||||
|
const PlkSrl = firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Plk1 !== undefined &&
|
||||||
|
Plk2 !== undefined &&
|
||||||
|
Plk3 !== undefined &&
|
||||||
|
PlkSrl !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
Plk1: normalizePlateNumber(Plk1),
|
||||||
|
Plk2: normalizePlateNumber(Plk2),
|
||||||
|
Plk3: normalizePlateNumber(Plk3),
|
||||||
|
PlkSrl: normalizePlateNumber(PlkSrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inquiryWithRetry(type, body) {
|
||||||
|
const endpoint = getInquiryEndpoint(type);
|
||||||
|
const url = endpoint.url;
|
||||||
|
const token = endpoint.token;
|
||||||
|
const accept = endpoint.accept;
|
||||||
|
let lastError;
|
||||||
|
|
||||||
|
const maxAttempts = config.retryEnabled ? config.retryCount : 1;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
await waitForRateLimit();
|
||||||
|
try {
|
||||||
|
console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts);
|
||||||
|
return await postJson(url, token, body, accept);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`);
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(config.retryDelayMs * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url, token, body, accept = "application/json") {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept,
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
|
||||||
|
error.status = response.status;
|
||||||
|
error.data = data;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInquirySuccessful(type, response) {
|
||||||
|
if (type === "CAR_BODY") return response && response.isSuccess === true && response.data;
|
||||||
|
if (type === "PERSON") return response && response.status === 200 && response.data;
|
||||||
|
return response && response.resultStatus === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInquiryEndpoint(type) {
|
||||||
|
if (type === "CAR_BODY") {
|
||||||
|
return { url: config.carBodyUrl, token: config.carBodyToken, accept: "application/json" };
|
||||||
|
}
|
||||||
|
if (type === "PERSON") {
|
||||||
|
return { url: config.personUrl, token: config.personToken, accept: "*/*" };
|
||||||
|
}
|
||||||
|
return { url: config.thirdPartyUrl, token: config.thirdPartyToken, accept: "application/json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInquiryToParty(type, party, response, originalPlate) {
|
||||||
|
if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate);
|
||||||
|
return applyThirdPartyInquiryToParty(party, response, originalPlate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThirdPartyInquiryToParty(party, response, originalPlate) {
|
||||||
|
const next = clone(party);
|
||||||
|
if (!next.vehicle) next.vehicle = {};
|
||||||
|
if (!next.insurance) next.insurance = {};
|
||||||
|
|
||||||
|
const mapped = normalizeThirdPartyResponse(response, originalPlate);
|
||||||
|
const plateId = buildPlateId(originalPlate);
|
||||||
|
|
||||||
|
next.vehicle.plateId = plateId || next.vehicle.plateId;
|
||||||
|
|
||||||
|
next.vehicle.inquiry = {
|
||||||
|
source: "TEJARAT_BLOCK_INQUIRY",
|
||||||
|
raw: response,
|
||||||
|
mapped,
|
||||||
|
refreshedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.carBody) {
|
||||||
|
next.vehicle.inquiry.carBody = party.vehicle.inquiry.carBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.vehicle.name = mapped.vehiclePersianName || mapped.MapTypNam || "اطلاعات این گزینه در استعلام موجود نیست";
|
||||||
|
next.vehicle.type = mapped.persianCarType || mapped.MapUsageName || next.vehicle.type;
|
||||||
|
next.insurance.policyNumber =
|
||||||
|
mapped.LastCompanyDocumentNumber || mapped.insuranceNumber || next.insurance.policyNumber;
|
||||||
|
next.insurance.company = mapped.companyPersianName || next.insurance.company;
|
||||||
|
next.insurance.financialCeiling = mapped.financeCoverage || next.insurance.financialCeiling;
|
||||||
|
next.insurance.startDate = mapped.persianStartDate || next.insurance.startDate;
|
||||||
|
next.insurance.endDate = mapped.persianEndDate || next.insurance.endDate;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCarBodyInquiryToParty(party, response, originalPlate) {
|
||||||
|
const next = clone(party);
|
||||||
|
if (!next.vehicle) next.vehicle = {};
|
||||||
|
if (!next.insurance) next.insurance = {};
|
||||||
|
|
||||||
|
const mapped = normalizeCarBodyResponse(response, originalPlate);
|
||||||
|
const plateId = buildPlateId(originalPlate);
|
||||||
|
|
||||||
|
next.vehicle.plateId = plateId || next.vehicle.plateId;
|
||||||
|
if (!next.vehicle.inquiry || typeof next.vehicle.inquiry !== "object") {
|
||||||
|
next.vehicle.inquiry = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
next.vehicle.inquiry.carBody = {
|
||||||
|
source: "TEJARAT_CAR_BODY_INQUIRY",
|
||||||
|
raw: response,
|
||||||
|
mapped,
|
||||||
|
refreshedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
next.vehicle.name = mapped.vehicleSystemTitle || next.vehicle.name;
|
||||||
|
next.vehicle.type = mapped.vehicleGroupTitle || next.vehicle.type;
|
||||||
|
next.insurance.carBodyInsurance = {
|
||||||
|
policyNumber: mapped.policyNumber,
|
||||||
|
companyId: mapped.companyId,
|
||||||
|
companyName: mapped.CompanyName,
|
||||||
|
insurerName: mapped.insurerName,
|
||||||
|
insurerNationalCode: mapped.insurerNationalCode,
|
||||||
|
ownerNationalCode: mapped.ownerNationalCode,
|
||||||
|
chassisNumber: mapped.chassisNumber,
|
||||||
|
vin: mapped.vin,
|
||||||
|
motorNumber: mapped.motorNumber,
|
||||||
|
vehicleGroup: mapped.vehicleGroupTitle,
|
||||||
|
vehicleSystem: mapped.vehicleSystemTitle,
|
||||||
|
startDate: mapped.StartDate,
|
||||||
|
endDate: mapped.EndDate,
|
||||||
|
issueDate: mapped.IssueDate,
|
||||||
|
noLossYearsCount: mapped.noLossYearsCount,
|
||||||
|
lossDocuments: Array.isArray(mapped.lossDocuments) ? mapped.lossDocuments : [],
|
||||||
|
hasEndorsement: mapped.hasEndorsement,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersonInquiryToCaseInquiries(inquiries, party, index, has, data, error) {
|
||||||
|
if (!inquiries.person || typeof inquiries.person !== "object") {
|
||||||
|
inquiries.person = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingData =
|
||||||
|
inquiries.person.data && typeof inquiries.person.data === "object" && !Array.isArray(inquiries.person.data)
|
||||||
|
? inquiries.person.data
|
||||||
|
: {};
|
||||||
|
const existingError =
|
||||||
|
inquiries.person.error && typeof inquiries.person.error === "object" && !Array.isArray(inquiries.person.error)
|
||||||
|
? inquiries.person.error
|
||||||
|
: {};
|
||||||
|
const roleKey = party && party.role ? party.role : `party_${index}`;
|
||||||
|
const nextData = { ...existingData };
|
||||||
|
const nextError = { ...existingError };
|
||||||
|
|
||||||
|
if (has) {
|
||||||
|
nextData[roleKey] = data || {};
|
||||||
|
delete nextError[roleKey];
|
||||||
|
} else {
|
||||||
|
nextError[roleKey] = normalizeInquiryError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
inquiries.person = {
|
||||||
|
has: Object.keys(nextData).length > 0,
|
||||||
|
data: nextData,
|
||||||
|
...(Object.keys(nextError).length > 0 ? { error: nextError } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersonResponse(response) {
|
||||||
|
return response && response.data ? response.data : response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInquiryError(error) {
|
||||||
|
if (!error) return undefined;
|
||||||
|
return {
|
||||||
|
message: error.message || String(error),
|
||||||
|
status: error.status,
|
||||||
|
data: error.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlateId(plate) {
|
||||||
|
if (!plate) return "";
|
||||||
|
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
|
||||||
|
if (!serialLetter) return "";
|
||||||
|
return (
|
||||||
|
cleanString(plate.PlkSrl) +
|
||||||
|
"-" +
|
||||||
|
cleanString(plate.Plk1) +
|
||||||
|
"-" +
|
||||||
|
serialLetter +
|
||||||
|
"-" +
|
||||||
|
cleanString(plate.Plk3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThirdPartyResponse(response, originalPlate) {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
Plk1: toNumber(originalPlate.Plk1),
|
||||||
|
Plk2: toNumber(originalPlate.Plk2),
|
||||||
|
Plk3: toNumber(originalPlate.Plk3),
|
||||||
|
PlkSrl: toNumber(originalPlate.PlkSrl),
|
||||||
|
CompanyName: response.companyPersianName,
|
||||||
|
CompanyCode: response.companyId,
|
||||||
|
LastCompanyDocumentNumber: response.lastCompanyInsuranceNumber || response.insuranceNumber,
|
||||||
|
FinancialCvrCptl: response.financeCoverage,
|
||||||
|
IssueDate: response.hIsuDte || response.persianStartDate,
|
||||||
|
SatrtDate: response.persianStartDate,
|
||||||
|
EndDate: response.persianEndDate,
|
||||||
|
MapTypNam: response.vehiclePersianName,
|
||||||
|
MapUsageName: response.MapUsageName || response.persianCarType,
|
||||||
|
UsageField: response.persianCarType,
|
||||||
|
UsageCode: response.usgCod,
|
||||||
|
VehicleSystemCode: response.vehSysCod,
|
||||||
|
CarGroupCode: response.carGrpCod,
|
||||||
|
EdrsJson: Array.isArray(response.edrSes) ? JSON.stringify(response.edrSes) : response.EdrsJson,
|
||||||
|
InsuranceFullName: response.fullname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCarBodyResponse(response, originalPlate) {
|
||||||
|
const data = response.data || {};
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
Plk1: toNumber(originalPlate.Plk1),
|
||||||
|
Plk2: toNumber(originalPlate.Plk2),
|
||||||
|
Plk3: toNumber(originalPlate.Plk3),
|
||||||
|
PlkSrl: toNumber(originalPlate.PlkSrl),
|
||||||
|
policyNumber: data.printNumber,
|
||||||
|
CompanyName: data.companyName,
|
||||||
|
CompanyCode: data.companyId,
|
||||||
|
LastCompanyDocumentNumber: data.printNumber,
|
||||||
|
InsuranceFullName: data.insurerName,
|
||||||
|
IssueDate: data.issueDate,
|
||||||
|
StartDate: data.beginDate,
|
||||||
|
SatrtDate: data.beginDate,
|
||||||
|
EndDate: data.endDate,
|
||||||
|
EngineNumberField: data.motorNumber,
|
||||||
|
MtrNum: data.motorNumber,
|
||||||
|
ChassisNumberField: data.chassisNumber,
|
||||||
|
ShsNum: data.chassisNumber,
|
||||||
|
VinNumberField: data.vin,
|
||||||
|
MapTypNam: data.vehicleGroupTitle,
|
||||||
|
isSuccess: response.isSuccess,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
message: response.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jalaliToGregorianDate(input) {
|
||||||
|
if (input === null || input === undefined) return null;
|
||||||
|
|
||||||
|
const raw = normalizeDigits(typeof input === "number" ? String(input) : String(input).trim());
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
let year = 0;
|
||||||
|
let month = 0;
|
||||||
|
let day = 0;
|
||||||
|
|
||||||
|
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
|
||||||
|
if (separated) {
|
||||||
|
year = parseInt(separated[1], 10);
|
||||||
|
month = parseInt(separated[2], 10);
|
||||||
|
day = parseInt(separated[3], 10);
|
||||||
|
} else {
|
||||||
|
const digits = raw.replace(/\D/g, "");
|
||||||
|
if (digits.length !== 8) return null;
|
||||||
|
year = parseInt(digits.slice(0, 4), 10);
|
||||||
|
month = parseInt(digits.slice(4, 6), 10);
|
||||||
|
day = parseInt(digits.slice(6, 8), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!year || !month || !day) return null;
|
||||||
|
|
||||||
|
if (year >= 1900) {
|
||||||
|
const mm = String(month).padStart(2, "0");
|
||||||
|
const dd = String(day).padStart(2, "0");
|
||||||
|
const result = `${year}-${mm}-${dd}`;
|
||||||
|
return isNaN(new Date(result).getTime()) ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jalaliPartsToGregorian(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jalaliPartsToGregorian(jYear, jMonth, jDay) {
|
||||||
|
const jy = jYear - 979;
|
||||||
|
const jm = jMonth - 1;
|
||||||
|
const jd = jDay - 1;
|
||||||
|
|
||||||
|
let jDayNo =
|
||||||
|
365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4);
|
||||||
|
|
||||||
|
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
||||||
|
for (let i = 0; i < jm; i += 1) {
|
||||||
|
jDayNo += jalaliMonthDays[i];
|
||||||
|
}
|
||||||
|
jDayNo += jd;
|
||||||
|
|
||||||
|
let gDayNo = jDayNo + 79;
|
||||||
|
|
||||||
|
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
|
||||||
|
gDayNo %= 146097;
|
||||||
|
|
||||||
|
let leap = true;
|
||||||
|
if (gDayNo >= 36525) {
|
||||||
|
gDayNo -= 1;
|
||||||
|
gy += 100 * Math.floor(gDayNo / 36524);
|
||||||
|
gDayNo %= 36524;
|
||||||
|
if (gDayNo >= 365) gDayNo += 1;
|
||||||
|
else leap = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gy += 4 * Math.floor(gDayNo / 1461);
|
||||||
|
gDayNo %= 1461;
|
||||||
|
|
||||||
|
if (gDayNo >= 366) {
|
||||||
|
leap = false;
|
||||||
|
gDayNo -= 1;
|
||||||
|
gy += Math.floor(gDayNo / 365);
|
||||||
|
gDayNo %= 365;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gregorianMonthDays = [
|
||||||
|
31,
|
||||||
|
leap ? 29 : 28,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
31,
|
||||||
|
];
|
||||||
|
|
||||||
|
let gm = 0;
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
if (gDayNo < gregorianMonthDays[i]) {
|
||||||
|
gm = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gDayNo -= gregorianMonthDays[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gd = gDayNo + 1;
|
||||||
|
const mm = String(gm).padStart(2, "0");
|
||||||
|
const dd = String(gd).padStart(2, "0");
|
||||||
|
const result = `${gy}-${mm}-${dd}`;
|
||||||
|
|
||||||
|
return isNaN(new Date(result).getTime()) ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRateLimit() {
|
||||||
|
const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute);
|
||||||
|
const elapsed = Date.now() - lastRequestAt;
|
||||||
|
if (lastRequestAt > 0 && elapsed < minDelayMs) {
|
||||||
|
const waitMs = minDelayMs - elapsed;
|
||||||
|
console.log(`[rate-limit] waiting ${waitMs}ms`);
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
lastRequestAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfig() {
|
||||||
|
if (!config.mongoUri) throw new Error("MONGO_URL is required");
|
||||||
|
if (!Number.isFinite(config.rateLimitPerMinute) || config.rateLimitPerMinute <= 0) {
|
||||||
|
throw new Error("RATE_LIMIT_PER_MINUTE must be a positive number");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(config.retryCount) || config.retryCount <= 0) {
|
||||||
|
throw new Error("RETRY_COUNT must be a positive number");
|
||||||
|
}
|
||||||
|
if (!config.thirdPartyToken) {
|
||||||
|
throw new Error("TEJARAT_THIRD_PARTY_TOKEN or TEJARAT_TOKEN is required");
|
||||||
|
}
|
||||||
|
if (!config.carBodyToken) {
|
||||||
|
throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required");
|
||||||
|
}
|
||||||
|
if (!config.personToken) {
|
||||||
|
throw new Error("TEJARAT_PERSON_TOKEN or TEJARAT_TOKEN is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFiles(filePath) {
|
||||||
|
const candidates = path.isAbsolute(filePath)
|
||||||
|
? [filePath]
|
||||||
|
: [
|
||||||
|
path.resolve(process.cwd(), filePath),
|
||||||
|
path.resolve(__dirname, "..", filePath),
|
||||||
|
];
|
||||||
|
|
||||||
|
const loaded = [];
|
||||||
|
for (const candidate of [...new Set(candidates)]) {
|
||||||
|
if (!fs.existsSync(candidate)) continue;
|
||||||
|
loadEnvFile(candidate);
|
||||||
|
loaded.push(candidate);
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
const resolved = path.resolve(process.cwd(), filePath);
|
||||||
|
if (!fs.existsSync(resolved)) return;
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const equalIndex = trimmed.indexOf("=");
|
||||||
|
if (equalIndex === -1) continue;
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, equalIndex).trim();
|
||||||
|
let value = trimmed.slice(equalIndex + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (key && process.env[key] === undefined) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredEnv(...names) {
|
||||||
|
for (const name of names) {
|
||||||
|
if (process.env[name]) return process.env[name];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPresent(...values) {
|
||||||
|
return values.find((value) => value !== undefined && value !== null && value !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlateNumber(value) {
|
||||||
|
const cleaned = normalizeDigits(cleanString(value));
|
||||||
|
return cleaned === "" ? value : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDigits(value) {
|
||||||
|
return cleanString(value).replace(/./g, (char) => {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
|
||||||
|
if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
|
||||||
|
return char;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanString(value) {
|
||||||
|
return value === undefined || value === null ? "" : String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
323
scripts/seed-parsian-tehran.ts
Normal file
323
scripts/seed-parsian-tehran.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
|
||||||
|
*
|
||||||
|
* Usage (before starting the app):
|
||||||
|
* npm run seed:parsian-tehran
|
||||||
|
*
|
||||||
|
* Optional env:
|
||||||
|
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import mongoose, { Schema, Types } from "mongoose";
|
||||||
|
|
||||||
|
type BranchSeed = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
fullName?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
address: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldExpertSeed = {
|
||||||
|
nationalCode: string;
|
||||||
|
mobile?: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
branchCode: string;
|
||||||
|
branchName?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
title?: string;
|
||||||
|
expertCode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripQuotes(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||||
|
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||||
|
) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripInlineComment(value: string): string {
|
||||||
|
const hashIdx = value.indexOf(" #");
|
||||||
|
return hashIdx === -1 ? value : value.slice(0, hashIdx).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandEnvValue(value: string, env: NodeJS.ProcessEnv): string {
|
||||||
|
return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => env[key] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFile() {
|
||||||
|
const envPath = join(process.cwd(), ".env");
|
||||||
|
if (!existsSync(envPath)) return;
|
||||||
|
|
||||||
|
const raw: Record<string, string> = {};
|
||||||
|
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const idx = trimmed.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
const value = stripInlineComment(trimmed.slice(idx + 1).trim());
|
||||||
|
raw[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (process.env[key]) continue;
|
||||||
|
process.env[key] = stripQuotes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
|
||||||
|
for (let pass = 0; pass < 5; pass++) {
|
||||||
|
let changed = false;
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
const current = process.env[key];
|
||||||
|
if (!current || !current.includes("${")) continue;
|
||||||
|
const expanded = expandEnvValue(stripQuotes(current), process.env);
|
||||||
|
if (expanded !== current) {
|
||||||
|
process.env[key] = expanded;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) process.env[key] = stripQuotes(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMongoUri(): string {
|
||||||
|
const uri = process.env.MONGO_URI?.trim();
|
||||||
|
if (!uri) {
|
||||||
|
throw new Error("MONGO_URI is not set in .env");
|
||||||
|
}
|
||||||
|
if (!uri.startsWith("mongodb://") && !uri.startsWith("mongodb+srv://")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid MONGO_URI after env expansion: "${uri.slice(0, 40)}..."`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
|
||||||
|
const indexes = await collection.indexes();
|
||||||
|
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
|
||||||
|
if (emailIndex && !emailIndex.sparse) {
|
||||||
|
await collection.dropIndex(emailIndex.name);
|
||||||
|
console.log(`Dropped legacy non-sparse index: ${emailIndex.name}`);
|
||||||
|
}
|
||||||
|
await collection.createIndex({ email: 1 }, { unique: true, sparse: true });
|
||||||
|
await collection.createIndex(
|
||||||
|
{ clientKey: 1, nationalCode: 1 },
|
||||||
|
{ unique: true, sparse: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(password: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const salt = crypto.randomBytes(16).toString("hex");
|
||||||
|
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(`${salt}:${derivedKey.toString("hex")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientSchema = new Schema(
|
||||||
|
{
|
||||||
|
clientName: { type: Object, required: true },
|
||||||
|
clientCode: { type: Number, required: true },
|
||||||
|
useExpertMode: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{ collection: "clients", versionKey: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const BranchSchema = new Schema(
|
||||||
|
{
|
||||||
|
clientKey: { type: Schema.Types.ObjectId, required: true, index: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
code: { type: String, required: true },
|
||||||
|
city: { type: String, required: true },
|
||||||
|
state: { type: String, required: true },
|
||||||
|
address: { type: String, required: true },
|
||||||
|
phoneNumber: { type: String },
|
||||||
|
isActive: { type: Boolean, default: true },
|
||||||
|
},
|
||||||
|
{ collection: "branches", versionKey: false, timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
|
||||||
|
|
||||||
|
const FieldExpertSchema = new Schema(
|
||||||
|
{
|
||||||
|
firstName: { type: String, required: true },
|
||||||
|
lastName: { type: String, required: true },
|
||||||
|
email: { type: String, unique: true, sparse: true },
|
||||||
|
username: { type: String },
|
||||||
|
nationalCode: { type: String, index: true, sparse: true },
|
||||||
|
clientKey: { type: Schema.Types.ObjectId, index: true },
|
||||||
|
branchId: { type: Schema.Types.ObjectId, index: true },
|
||||||
|
password: { type: String, required: true },
|
||||||
|
mobile: { type: String },
|
||||||
|
phone: { type: String },
|
||||||
|
role: { type: String, default: "field_expert" },
|
||||||
|
otp: { type: String, default: "" },
|
||||||
|
expertCode: { type: String, required: false },
|
||||||
|
},
|
||||||
|
{ collection: "field-expert", versionKey: false, timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
FieldExpertSchema.index(
|
||||||
|
{ clientKey: 1, nationalCode: 1 },
|
||||||
|
{ unique: true, sparse: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnvFile();
|
||||||
|
const mongoUri = resolveMongoUri();
|
||||||
|
|
||||||
|
const dataDir = join(process.cwd(), "scripts/data/parsian-tehran");
|
||||||
|
const branchesFile = JSON.parse(
|
||||||
|
readFileSync(join(dataDir, "branches.json"), "utf8"),
|
||||||
|
) as { clientCode: number; branches: BranchSeed[] };
|
||||||
|
const expertsFile = JSON.parse(
|
||||||
|
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
|
||||||
|
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
|
||||||
|
|
||||||
|
const defaultPassword =
|
||||||
|
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
|
||||||
|
const hashedPassword = await hashPassword(defaultPassword);
|
||||||
|
|
||||||
|
await mongoose.connect(mongoUri, {
|
||||||
|
tls: process.env.MONGO_TLS === "true",
|
||||||
|
tlsAllowInvalidCertificates:
|
||||||
|
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
|
||||||
|
});
|
||||||
|
const Client = mongoose.model("ClientSeedClient", ClientSchema);
|
||||||
|
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
|
||||||
|
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
|
||||||
|
|
||||||
|
await ensureFieldExpertIndexes(FieldExpert.collection);
|
||||||
|
|
||||||
|
const client = await Client.findOne({
|
||||||
|
clientCode: branchesFile.clientCode,
|
||||||
|
}).lean();
|
||||||
|
if (!client?._id) {
|
||||||
|
throw new Error(
|
||||||
|
`Client with clientCode=${branchesFile.clientCode} not found in database`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clientKey = new Types.ObjectId(String(client._id));
|
||||||
|
|
||||||
|
const branchIdByCode = new Map<string, Types.ObjectId>();
|
||||||
|
let branchesCreated = 0;
|
||||||
|
let branchesUpdated = 0;
|
||||||
|
|
||||||
|
for (const branch of branchesFile.branches) {
|
||||||
|
const existing = await Branch.findOne({
|
||||||
|
clientKey,
|
||||||
|
code: branch.code,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
clientKey,
|
||||||
|
name: branch.name,
|
||||||
|
code: branch.code,
|
||||||
|
city: branch.city,
|
||||||
|
state: branch.state,
|
||||||
|
address: branch.address,
|
||||||
|
phoneNumber: branch.phoneNumber,
|
||||||
|
isActive: branch.isActive ?? true,
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await Branch.updateOne({ _id: existing._id }, { $set: payload });
|
||||||
|
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
|
||||||
|
branchesUpdated++;
|
||||||
|
} else {
|
||||||
|
const created = await Branch.create(payload);
|
||||||
|
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
|
||||||
|
branchesCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expertsCreated = 0;
|
||||||
|
let expertsUpdated = 0;
|
||||||
|
let expertsSkipped = 0;
|
||||||
|
|
||||||
|
for (const expert of expertsFile.fieldExperts) {
|
||||||
|
const branchId = branchIdByCode.get(expert.branchCode);
|
||||||
|
if (!branchId) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
|
||||||
|
);
|
||||||
|
expertsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
firstName: expert.firstName,
|
||||||
|
lastName: expert.lastName,
|
||||||
|
username: expert.nationalCode,
|
||||||
|
nationalCode: expert.nationalCode,
|
||||||
|
clientKey,
|
||||||
|
branchId,
|
||||||
|
password: hashedPassword,
|
||||||
|
mobile: expert.mobile,
|
||||||
|
role: "field_expert",
|
||||||
|
otp: "",
|
||||||
|
expertCode: expert.expertCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await FieldExpert.findOne({
|
||||||
|
clientKey,
|
||||||
|
nationalCode: expert.nationalCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await FieldExpert.updateOne(
|
||||||
|
{ _id: existing._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...payload,
|
||||||
|
// Do not rotate password on re-seed unless explicitly desired.
|
||||||
|
password: existing.password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expertsUpdated++;
|
||||||
|
} else {
|
||||||
|
await FieldExpert.create(payload);
|
||||||
|
expertsCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Parsian Tehran seed completed.");
|
||||||
|
console.log({
|
||||||
|
clientCode: branchesFile.clientCode,
|
||||||
|
clientKey: String(clientKey),
|
||||||
|
branchesCreated,
|
||||||
|
branchesUpdated,
|
||||||
|
expertsCreated,
|
||||||
|
expertsUpdated,
|
||||||
|
expertsSkipped,
|
||||||
|
defaultPassword,
|
||||||
|
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
|
||||||
|
});
|
||||||
|
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -3,6 +3,9 @@ export enum WorkflowStep {
|
|||||||
|
|
||||||
CREATED = "CREATED",
|
CREATED = "CREATED",
|
||||||
|
|
||||||
|
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||||
|
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||||
|
|
||||||
// ---------- First party ----------
|
// ---------- First party ----------
|
||||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||||
FIRST_VIDEO = "FIRST_VIDEO",
|
FIRST_VIDEO = "FIRST_VIDEO",
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
//! NEW
|
//! NEW
|
||||||
export enum CaseStatus {
|
export enum CaseStatus {
|
||||||
|
|
||||||
OPEN = "OPEN",
|
OPEN = "OPEN",
|
||||||
|
|
||||||
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
||||||
|
|
||||||
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
||||||
|
|
||||||
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
||||||
|
|
||||||
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
||||||
|
|
||||||
COMPLETED = "COMPLETED",
|
COMPLETED = "COMPLETED",
|
||||||
|
|
||||||
CANCELLED = "CANCELLED",
|
CANCELLED = "CANCELLED",
|
||||||
|
|
||||||
AUTO_CLOSED = "AUTO_CLOSED"
|
AUTO_CLOSED = "AUTO_CLOSED",
|
||||||
}
|
|
||||||
|
STOPPED = "STOPPED"
|
||||||
|
}
|
||||||
97
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
97
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ResendItemType } from "./resendItemType.enum";
|
||||||
|
|
||||||
|
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
|
||||||
|
*/
|
||||||
|
export function normalizeResendRequestedItemKey(raw: string): string | null {
|
||||||
|
const t = String(raw ?? "").trim();
|
||||||
|
if (!t) return null;
|
||||||
|
if (RESEND_ITEM_VALUES.has(t)) return t;
|
||||||
|
const lower = t.toLowerCase();
|
||||||
|
for (const v of RESEND_ITEM_VALUES) {
|
||||||
|
if (v.toLowerCase() === lower) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deduplicated list of valid requested item keys. */
|
||||||
|
export function normalizeResendRequestedItemsList(
|
||||||
|
items: string[] | undefined | null,
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of items || []) {
|
||||||
|
const c = normalizeResendRequestedItemKey(String(raw));
|
||||||
|
if (c && !seen.has(c)) {
|
||||||
|
seen.add(c);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
|
||||||
|
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
|
||||||
|
*/
|
||||||
|
export function cloneResendUploadedDocuments(
|
||||||
|
raw: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (raw == null || typeof raw !== "object") return {};
|
||||||
|
if (raw instanceof Map) {
|
||||||
|
const o: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of raw.entries()) {
|
||||||
|
o[String(k)] = v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
return { ...(raw as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||||
|
export type ResendItemInputKind =
|
||||||
|
| "document_camera"
|
||||||
|
| "voice"
|
||||||
|
| "video"
|
||||||
|
| "text";
|
||||||
|
|
||||||
|
export function getResendItemInputKind(item: string): ResendItemInputKind {
|
||||||
|
if (item === ResendItemType.VOICE) return "voice";
|
||||||
|
if (item === ResendItemType.DESCRIPTION) return "text";
|
||||||
|
return "document_camera";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResendItemsWithUi(requestedItems: string[]) {
|
||||||
|
const normalized = normalizeResendRequestedItemsList(requestedItems);
|
||||||
|
return normalized.map((item) => ({
|
||||||
|
item,
|
||||||
|
inputKind: getResendItemInputKind(item),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a single requested item is satisfied on a party's resend row (after merges). */
|
||||||
|
export function isResendPartyItemSatisfied(
|
||||||
|
item: string,
|
||||||
|
row: {
|
||||||
|
uploadedDocuments?: Record<string, unknown>;
|
||||||
|
resendVoiceId?: unknown;
|
||||||
|
resendVideoId?: unknown;
|
||||||
|
userTextDescription?: string;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const uploaded = row.uploadedDocuments || {};
|
||||||
|
if (item === ResendItemType.DESCRIPTION) {
|
||||||
|
return !!(
|
||||||
|
row.userTextDescription && String(row.userTextDescription).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item === ResendItemType.VOICE) {
|
||||||
|
return !!row.resendVoiceId;
|
||||||
|
}
|
||||||
|
// if (item === ResendItemType.VIDEO) {
|
||||||
|
// return !!row.resendVideoId;
|
||||||
|
// }
|
||||||
|
return !!uploaded[item];
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ export enum ResendItemType {
|
|||||||
CAR_CERTIFICATE = "carCertificate",
|
CAR_CERTIFICATE = "carCertificate",
|
||||||
DRIVING_LICENSE = "drivingLicense",
|
DRIVING_LICENSE = "drivingLicense",
|
||||||
CAR_GREEN_CARD = "carGreenCard",
|
CAR_GREEN_CARD = "carGreenCard",
|
||||||
|
CAR_PLATE = "carPlate",
|
||||||
|
CHASSIS_NUMBER = "chassisNumber",
|
||||||
// Media evidence
|
// Media evidence
|
||||||
VOICE = "voice",
|
VOICE = "voice",
|
||||||
VIDEO = "video",
|
|
||||||
|
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||||
|
DESCRIPTION = "description",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,30 @@ export enum ClaimCaseStatus {
|
|||||||
// User flow - damage capture
|
// User flow - damage capture
|
||||||
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
|
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
|
||||||
|
|
||||||
|
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
|
||||||
|
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
|
||||||
|
|
||||||
// Expert flow
|
// Expert flow
|
||||||
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
|
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
|
||||||
EXPERT_REVIEWING = "EXPERT_REVIEWING",
|
EXPERT_REVIEWING = "EXPERT_REVIEWING",
|
||||||
|
/**
|
||||||
|
* @deprecated Prefer specific post-expert statuses below. Kept for existing DB rows and reads.
|
||||||
|
* Historically used for all owner/insurer steps after expert pricing.
|
||||||
|
*/
|
||||||
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
|
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
|
||||||
|
|
||||||
|
/** Expert reply has only priced lines (`factorNeeded=false` everywhere). Owner final accept/reject at `INSURER_REVIEW`. Also set after expert finishes repair-factor validation when the case returns to final owner sign-off. */
|
||||||
|
INSURER_REVIEW_AWAITING_OWNER_SIGN = "INSURER_REVIEW_AWAITING_OWNER_SIGN",
|
||||||
|
|
||||||
|
/** Expert reply mixes priced lines and factor-needed lines: owner signs priced lines, then uploads factors (status stays through both sub-steps). */
|
||||||
|
INSURER_REVIEW_MIXED_FACTORS_PENDING = "INSURER_REVIEW_MIXED_FACTORS_PENDING",
|
||||||
|
|
||||||
|
/** Expert reply requires a repair-factor file for every line before cost validation. */
|
||||||
|
OWNER_REPAIR_FACTOR_UPLOAD_PENDING = "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
||||||
|
|
||||||
|
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
||||||
|
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
||||||
|
|
||||||
// Final states
|
// Final states
|
||||||
COMPLETED = "COMPLETED",
|
COMPLETED = "COMPLETED",
|
||||||
CANCELLED = "CANCELLED",
|
CANCELLED = "CANCELLED",
|
||||||
|
|||||||
@@ -7,19 +7,27 @@ export enum ClaimWorkflowStep {
|
|||||||
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
|
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
|
||||||
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
|
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
|
||||||
|
|
||||||
// User: Documentation phase
|
// User: Damage capture phase (angles + per-part photos) — before document upload in v2
|
||||||
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
|
||||||
|
|
||||||
// User: Damage capture phase (per part)
|
|
||||||
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
|
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
|
||||||
|
|
||||||
|
// User: Documentation phase — after captures in v2
|
||||||
|
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
||||||
|
|
||||||
// User submission complete
|
// User submission complete
|
||||||
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
|
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
|
||||||
|
|
||||||
|
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
|
||||||
|
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
|
||||||
|
|
||||||
// Expert: Damage assessment
|
// Expert: Damage assessment
|
||||||
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
|
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
|
||||||
|
/** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */
|
||||||
|
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
|
||||||
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||||
|
|
||||||
|
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
|
||||||
|
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
|
||||||
|
|
||||||
// Insurer approval
|
// Insurer approval
|
||||||
INSURER_REVIEW = "INSURER_REVIEW",
|
INSURER_REVIEW = "INSURER_REVIEW",
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
|
|||||||
CarCertificate = "carCertificate",
|
CarCertificate = "carCertificate",
|
||||||
DrivingLicense = "drivingLicense",
|
DrivingLicense = "drivingLicense",
|
||||||
CarGreenCard = "carGreenCard",
|
CarGreenCard = "carGreenCard",
|
||||||
|
Plate = "plate",
|
||||||
|
CarPlate = "carPlate",
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export enum ClaimRequiredDocumentType {
|
export enum ClaimRequiredDocumentType {
|
||||||
// Car green card
|
// Car green card
|
||||||
CAR_GREEN_CARD = "car_green_card",
|
CAR_GREEN_CARD = "car_green_card",
|
||||||
|
CAR_CERTIFICATE = "car_certificate",
|
||||||
|
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
|
||||||
|
NATIONAL_CARD = "national_card",
|
||||||
|
|
||||||
// Damaged party documents
|
// Damaged party documents
|
||||||
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
|
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export enum RoleEnum {
|
|||||||
EXPERT = "expert",
|
EXPERT = "expert",
|
||||||
DAMAGE_EXPERT = "damage_expert",
|
DAMAGE_EXPERT = "damage_expert",
|
||||||
FIELD_EXPERT = "field_expert",
|
FIELD_EXPERT = "field_expert",
|
||||||
|
REGISTRAR = "registrar",
|
||||||
COMPANY = "company",
|
COMPANY = "company",
|
||||||
ADMIN = "admin",
|
ADMIN = "admin",
|
||||||
USER = "user",
|
USER = "user",
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import axios, { AxiosRequestConfig } from "axios";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import * as FormData from "form-data";
|
import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService implements OnModuleInit {
|
export class AiService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(AiService.name);
|
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
private accessToken: string = null;
|
private accessToken: string = null;
|
||||||
|
|
||||||
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
|
|||||||
private readonly loginOptions: AxiosRequestConfig = {
|
private readonly loginOptions: AxiosRequestConfig = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
url: `${process.env.AI_URL_V2}/auth/login`,
|
url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
|
||||||
data: {
|
data: {
|
||||||
username: process.env.AI_USERNAME,
|
username: this.configService.get<string>("AI_USERNAME"),
|
||||||
password: process.env.AI_PASSWORD,
|
password: this.configService.get<string>("AI_PASSWORD"),
|
||||||
},
|
},
|
||||||
timeout: 30000, // 30 second timeout
|
timeout: 1000, // TODO: Make this ENV
|
||||||
};
|
};
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
private get profileOptions(): AxiosRequestConfig {
|
private get profileOptions(): AxiosRequestConfig {
|
||||||
return {
|
return {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${process.env.AI_URL_V2}/auth/profile`,
|
url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
Authorization: `Bearer ${this.accessToken}`,
|
||||||
},
|
},
|
||||||
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
try {
|
try {
|
||||||
const res = await this.login();
|
const res = await this.login();
|
||||||
if (res?.accessToken) {
|
// if (res?.accessToken) {
|
||||||
this.logger.verbose("AI Service Authenticated Successfully.");
|
// this.accessToken = res.accessToken;
|
||||||
this.accessToken = res.accessToken;
|
// await this.getApiKey();
|
||||||
await this.getApiKey();
|
// }
|
||||||
this.logger.log("AI Service initialized and ready.");
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
"AI Service Unavailable: Login did not return an access token. Will retry on first request.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't prevent app startup if AI service is temporarily unavailable
|
// Don't prevent app startup if AI service is temporarily unavailable
|
||||||
// The service will attempt to re-authenticate when aiRequestImage is called
|
// The service will attempt to re-authenticate when aiRequestImage is called
|
||||||
this.logger.warn(
|
|
||||||
"AI Service Unavailable: Failed during initial login. Will retry on first request.",
|
|
||||||
);
|
|
||||||
this.logger.warn(`Error: ${error.message}`);
|
|
||||||
// Reset tokens so re-authentication will be attempted
|
// Reset tokens so re-authentication will be attempted
|
||||||
this.accessToken = null;
|
this.accessToken = null;
|
||||||
this.apiKey = null;
|
this.apiKey = null;
|
||||||
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async login() {
|
private async login() {
|
||||||
try {
|
// const loginResponse = await axios.request(this.loginOptions);
|
||||||
const loginResponse = await axios.request(this.loginOptions);
|
// return loginResponse.data;
|
||||||
return loginResponse.data;
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err.response?.data?.message || err.message || "Unknown error";
|
|
||||||
const statusCode = err.response?.status || 500;
|
|
||||||
this.logger.error(`AI login failed: ${errorMessage} (Status: ${statusCode})`);
|
|
||||||
if (err.response?.data) {
|
|
||||||
this.logger.error(`AI login error details: ${JSON.stringify(err.response.data, null, 2)}`);
|
|
||||||
}
|
|
||||||
throw new HttpException(
|
|
||||||
`Could not authenticate with AI service: ${errorMessage}`,
|
|
||||||
statusCode >= 400 && statusCode < 500 ? statusCode : HttpStatus.UNAUTHORIZED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getApiKey() {
|
private async getApiKey() {
|
||||||
try {
|
// const profileResponse = await axios.request(this.profileOptions);
|
||||||
const profileResponse = await axios.request(this.profileOptions);
|
// this.apiKey = profileResponse.data.apiKey.key;
|
||||||
this.apiKey = profileResponse.data.apiKey.key;
|
// return this.apiKey;
|
||||||
this.logger.log("Successfully retrieved AI gateway API key.");
|
|
||||||
return this.apiKey;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error("Failed to retrieve AI API key:", err.message);
|
|
||||||
throw new HttpException(
|
|
||||||
"Could not get API key from AI service",
|
|
||||||
HttpStatus.FAILED_DEPENDENCY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async aiRequestImage(file: { path: string; fileName?: string }): Promise<any> {
|
public async aiRequestImage(file: {
|
||||||
|
path: string;
|
||||||
|
fileName?: string;
|
||||||
|
}): Promise<any> {
|
||||||
// Ensure authentication is set up
|
// Ensure authentication is set up
|
||||||
if (!this.accessToken || !this.apiKey) {
|
if (!this.accessToken || !this.apiKey) {
|
||||||
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
|
|
||||||
try {
|
try {
|
||||||
const res = await this.login();
|
const res = await this.login();
|
||||||
if (res?.accessToken) {
|
// if (res?.accessToken) {
|
||||||
this.accessToken = res.accessToken;
|
// this.accessToken = res.accessToken;
|
||||||
await this.getApiKey();
|
// await this.getApiKey();
|
||||||
} else {
|
// } else {
|
||||||
throw new HttpException(
|
// throw new HttpException(
|
||||||
"AI Service authentication failed",
|
// "AI Service authentication failed",
|
||||||
HttpStatus.UNAUTHORIZED,
|
// HttpStatus.UNAUTHORIZED,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to re-authenticate AI service:", error.message);
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"AI Service authentication failed",
|
"AI Service authentication failed",
|
||||||
HttpStatus.UNAUTHORIZED,
|
HttpStatus.UNAUTHORIZED,
|
||||||
@@ -140,100 +107,69 @@ export class AiService implements OnModuleInit {
|
|||||||
? file.path
|
? file.path
|
||||||
: join(process.cwd(), file.path.replace(/^\.\//, ""));
|
: join(process.cwd(), file.path.replace(/^\.\//, ""));
|
||||||
|
|
||||||
this.logger.log(`Processing AI image request for: ${filePath}`);
|
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
this.logger.error(`File not found at path: ${filePath}`);
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`File not found: ${file.path}`,
|
`File not found: ${file.path}`,
|
||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = new FormData();
|
// const form = new FormData();
|
||||||
const fileStream = createReadStream(filePath);
|
const fileStream = createReadStream(filePath);
|
||||||
|
|
||||||
// Append file with filename if available
|
// Append file with filename if available
|
||||||
if (file.fileName) {
|
if (file.fileName) {
|
||||||
form.append("images", fileStream, file.fileName);
|
// form.append("images", fileStream, file.fileName);
|
||||||
} else {
|
} else {
|
||||||
// Extract filename from path if not provided
|
// Extract filename from path if not provided
|
||||||
const pathParts = filePath.split("/");
|
const pathParts = filePath.split("/");
|
||||||
const extractedFileName = pathParts[pathParts.length - 1];
|
const extractedFileName = pathParts[pathParts.length - 1];
|
||||||
form.append("images", fileStream, extractedFileName);
|
// form.append("images", fileStream, extractedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
...this.imageProcessOptions.headers,
|
...this.imageProcessOptions.headers,
|
||||||
...form.getHeaders(),
|
// ...form.getHeaders(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`);
|
const fs = require("fs");
|
||||||
this.logger.log(`[STEP 1/4] File: ${filePath}, Filename: ${file.fileName || 'extracted from path'}`);
|
|
||||||
this.logger.log(`[STEP 1/4] FormData Content-Type: ${form.getHeaders()['content-type']}`);
|
|
||||||
this.logger.log(`[STEP 1/4] Authorization header present: ${!!requestHeaders.Authorization}`);
|
|
||||||
this.logger.log(`[STEP 1/4] Gateway API key present: ${!!this.apiKey}`);
|
|
||||||
this.logger.log(`[STEP 1/4] Request method: POST`);
|
|
||||||
this.logger.log(`[STEP 1/4] FormData field name: "images"`);
|
|
||||||
|
|
||||||
// Get file stats for debugging
|
|
||||||
const fs = require('fs');
|
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
this.logger.log(`[STEP 1/4] File size: ${stats.size} bytes`);
|
|
||||||
this.logger.log(`[STEP 1/4] File exists: ${existsSync(filePath)}`);
|
|
||||||
|
|
||||||
const response = await axios.request({
|
// const response = await axios.request({
|
||||||
...this.imageProcessOptions,
|
// ...this.imageProcessOptions,
|
||||||
headers: requestHeaders,
|
// headers: requestHeaders,
|
||||||
data: form,
|
// // data: form,
|
||||||
maxContentLength: Infinity,
|
// maxContentLength: Infinity,
|
||||||
maxBodyLength: Infinity,
|
// maxBodyLength: Infinity,
|
||||||
});
|
// });
|
||||||
|
|
||||||
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`);
|
|
||||||
|
|
||||||
// Validate response structure
|
// Validate response structure
|
||||||
if (!response.data) {
|
// if (!response.data) {
|
||||||
this.logger.error(`[ERROR] AI response is empty or missing data`);
|
// throw new HttpException(
|
||||||
throw new HttpException(
|
// "AI Service returned empty response",
|
||||||
"AI Service returned empty response",
|
// HttpStatus.BAD_GATEWAY,
|
||||||
HttpStatus.BAD_GATEWAY,
|
// );
|
||||||
);
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error in response first (AI service returns 201 with error in body)
|
// // Check for error in response first (AI service returns 201 with error in body)
|
||||||
if (response.data.error) {
|
// if (response.data.error) {
|
||||||
this.logger.error(`[ERROR] AI service returned an error in response body`);
|
// throw new HttpException(
|
||||||
this.logger.error(`[ERROR] Error message: ${response.data.error}`);
|
// `AI Service error: ${response.data.error}`,
|
||||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
// HttpStatus.BAD_GATEWAY,
|
||||||
throw new HttpException(
|
// );
|
||||||
`AI Service error: ${response.data.error}`,
|
// }
|
||||||
HttpStatus.BAD_GATEWAY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for processed image (downloadLink)
|
// // Check for processed image (downloadLink)
|
||||||
if (!response.data.downloadLink) {
|
// if (!response.data.downloadLink) {
|
||||||
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`);
|
// throw new HttpException(
|
||||||
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`);
|
// "AI Service did not return processed image (downloadLink missing)",
|
||||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
// HttpStatus.BAD_GATEWAY,
|
||||||
throw new HttpException(
|
// );
|
||||||
"AI Service did not return processed image (downloadLink missing)",
|
// }
|
||||||
HttpStatus.BAD_GATEWAY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for reports
|
// return response.data;
|
||||||
if (!response.data.reports) {
|
|
||||||
this.logger.warn(`[WARNING] AI response missing reports object, but downloadLink exists`);
|
|
||||||
this.logger.warn(`[WARNING] Response keys: ${JSON.stringify(Object.keys(response.data))}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[STEP 3/4] Validated AI response - downloadLink: ${response.data.downloadLink ? 'present' : 'missing'}, reports: ${response.data.reports ? 'present' : 'missing'}`);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (er) {
|
} catch (er) {
|
||||||
// Determine error source
|
// Determine error source
|
||||||
let errorSource = "UNKNOWN";
|
let errorSource = "UNKNOWN";
|
||||||
@@ -242,7 +178,10 @@ export class AiService implements OnModuleInit {
|
|||||||
|
|
||||||
if (er.response) {
|
if (er.response) {
|
||||||
errorSource = "AI_SERVICE_RESPONSE";
|
errorSource = "AI_SERVICE_RESPONSE";
|
||||||
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`;
|
errorMessage =
|
||||||
|
er.response?.data?.message ||
|
||||||
|
er.message ||
|
||||||
|
`HTTP ${er.response.status}`;
|
||||||
errorDetails = er.response?.data
|
errorDetails = er.response?.data
|
||||||
? JSON.stringify(er.response.data, null, 2)
|
? JSON.stringify(er.response.data, null, 2)
|
||||||
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
|
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
|
||||||
@@ -255,14 +194,6 @@ export class AiService implements OnModuleInit {
|
|||||||
errorMessage = er.message || "Error setting up request";
|
errorMessage = er.message || "Error setting up request";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`[ERROR] AI request failed - Source: ${errorSource}`);
|
|
||||||
this.logger.error(`[ERROR] File path: ${filePath}`);
|
|
||||||
this.logger.error(`[ERROR] Error message: ${errorMessage}`);
|
|
||||||
this.logger.error(`[ERROR] Error details: ${errorDetails}`);
|
|
||||||
if (er.stack) {
|
|
||||||
this.logger.error(`[ERROR] Stack trace: ${er.stack}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw with detailed error information
|
// Re-throw with detailed error information
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`[${errorSource}] ${errorMessage}`,
|
`[${errorSource}] ${errorMessage}`,
|
||||||
|
|||||||
@@ -1,51 +1,45 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
|
||||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||||
import * as dotenv from "dotenv";
|
|
||||||
import { CommandModule } from "nestjs-command";
|
|
||||||
import { AiModule } from "./ai/ai.module";
|
import { AiModule } from "./ai/ai.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
|
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
|
||||||
import { ClientModule } from "./client/client.module";
|
import { ClientModule } from "./client/client.module";
|
||||||
import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
||||||
|
import { FanavaranModule } from "./fanavaran/fanavaran.module";
|
||||||
import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
|
import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
|
||||||
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
|
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
|
||||||
import { LookupsModule } from "./lookups/lookups.module";
|
import { LookupsModule } from "./lookups/lookups.module";
|
||||||
import { PlatesModule } from "./plates/plates.module";
|
import { PlatesModule } from "./plates/plates.module";
|
||||||
import { ProfileModule } from "./profile/profile.module";
|
import { ProfileModule } from "./profile/profile.module";
|
||||||
import { SandHubModule } from "./sand-hub/sand-hub.module";
|
import { SandHubModule } from "./sand-hub/sand-hub.module";
|
||||||
|
import { SystemSettingsModule } from "./system-settings/system-settings.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { RequestManagementModule } from "./request-management/request-management.module";
|
import { RequestManagementModule } from "./request-management/request-management.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
||||||
import { CronModule } from "./utils/cron/cron.module";
|
import { CronModule } from "./utils/cron/cron.module";
|
||||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||||
|
import { DatabaseModule } from "./core/database/database.module";
|
||||||
dotenv.config();
|
import { AppConfigModule } from "./core/config/config.module";
|
||||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CommandModule,
|
AppConfigModule,
|
||||||
ScheduleModule.forRoot(),
|
DatabaseModule,
|
||||||
CronModule,
|
CronModule,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, "..", "files"),
|
// process.cwd() is always the project/container root (/app in Docker),
|
||||||
|
// so the volume-mounted /app/files is resolved correctly regardless of
|
||||||
|
// where the compiled dist files live (__dirname would resolve to
|
||||||
|
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
|
||||||
|
rootPath: join(process.cwd(), "files"),
|
||||||
serveRoot: "/files",
|
serveRoot: "/files",
|
||||||
}),
|
}),
|
||||||
MongooseModule.forRoot(
|
|
||||||
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
|
|
||||||
{
|
|
||||||
dbName: "yara724",
|
|
||||||
autoIndex: true,
|
|
||||||
user: process.env.MONGO_USER,
|
|
||||||
pass: process.env.MONGO_PASS,
|
|
||||||
authMechanism: "SCRAM-SHA-256",
|
|
||||||
tls: true,
|
|
||||||
tlsAllowInvalidCertificates: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
@@ -53,8 +47,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
|||||||
PlatesModule,
|
PlatesModule,
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
SandHubModule,
|
SandHubModule,
|
||||||
|
SystemSettingsModule,
|
||||||
ExpertBlameModule,
|
ExpertBlameModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
|
FanavaranModule,
|
||||||
ExpertClaimModule,
|
ExpertClaimModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
@@ -63,6 +59,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
|||||||
WorkflowStepManagementModule,
|
WorkflowStepManagementModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: UnicodeDigitsNormalizeInterceptor,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// provide: APP_PIPE,
|
||||||
|
// useValue: new ValidationPipe({
|
||||||
|
// transform: true,
|
||||||
|
// whitelist: true,
|
||||||
|
// forbidNonWhitelisted: false,
|
||||||
|
// }),
|
||||||
|
// },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
import {
|
import {
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiAcceptedResponse,
|
ApiAcceptedResponse,
|
||||||
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
|
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||||
|
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||||
|
import { GetCaptchaImageQueryDto } from "src/auth/dto/get-captcha-image-query.dto";
|
||||||
import {
|
import {
|
||||||
ForgetPasswordSendCodeDto,
|
ForgetPasswordSendCodeDto,
|
||||||
ForgetPasswordVerifyCodeDto,
|
ForgetPasswordVerifyCodeDto,
|
||||||
@@ -23,28 +30,93 @@ import {
|
|||||||
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
||||||
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
|
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
|
||||||
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
|
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
|
||||||
|
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
|
||||||
import {
|
import {
|
||||||
GenuineRegisterDto,
|
GenuineRegisterDto,
|
||||||
InsurerRegisterDto,
|
InsurerRegisterDto,
|
||||||
LegalRegisterDto,
|
LegalRegisterDto,
|
||||||
} from "src/auth/dto/actor/register.actor.dto";
|
} from "src/auth/dto/actor/register.actor.dto";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
|
||||||
@Controller("actor")
|
@Controller("actor")
|
||||||
@ApiTags("actor")
|
@ApiTags("actor")
|
||||||
export class ActorAuthController {
|
export class ActorAuthController {
|
||||||
constructor(private readonly actorAuthService: ActorAuthService) {}
|
constructor(
|
||||||
|
private readonly actorAuthService: ActorAuthService,
|
||||||
|
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("captcha")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get a login captcha",
|
||||||
|
description:
|
||||||
|
"Issues a new captcha challenge. Returns `captchaId`, `image`, and `expiresAt`. " +
|
||||||
|
"Send `captchaId` and the typed characters as `captcha` on POST /actor/login.\n\n" +
|
||||||
|
"Optional `format=raw` returns image/svg+xml for browser preview (same captchaId is in JSON when omitted).",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: CaptchaResponseDto })
|
||||||
|
async getCaptcha(
|
||||||
|
@Query() query: GetCaptchaImageQueryDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.captchaChallengeService.issue();
|
||||||
|
|
||||||
|
if (query.format === "raw") {
|
||||||
|
const svg = await this.captchaChallengeService.getImageById(
|
||||||
|
result.captchaId,
|
||||||
|
);
|
||||||
|
res.setHeader("X-Captcha-Id", result.captchaId);
|
||||||
|
res.type("image/svg+xml");
|
||||||
|
res.send(svg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("captcha/:captchaId/image")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "View captcha image by id",
|
||||||
|
description: "Returns raw SVG for a previously issued captcha challenge.",
|
||||||
|
})
|
||||||
|
async getCaptchaImage(
|
||||||
|
@Param("captchaId") captchaId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const svg = await this.captchaChallengeService.getImageById(captchaId);
|
||||||
|
res.type("image/svg+xml");
|
||||||
|
res.send(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||||
|
* will be removed in a future release.
|
||||||
|
*/
|
||||||
@Post("register/genuine")
|
@Post("register/genuine")
|
||||||
|
@ApiOperation({
|
||||||
|
deprecated: true,
|
||||||
|
summary: "[DEPRECATED] Genuine actor registration",
|
||||||
|
description:
|
||||||
|
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||||
|
})
|
||||||
@ApiBody({ type: GenuineRegisterDto })
|
@ApiBody({ type: GenuineRegisterDto })
|
||||||
async registerGenuine(@Body() body: GenuineRegisterDto) {
|
async registerGenuine(@Body() body: GenuineRegisterDto) {
|
||||||
return await this.actorAuthService.genuineRegister(body);
|
return await this.actorAuthService.genuineRegister(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||||
|
* will be removed in a future release.
|
||||||
|
*/
|
||||||
@Post("register/legal")
|
@Post("register/legal")
|
||||||
|
@ApiOperation({
|
||||||
|
deprecated: true,
|
||||||
|
summary: "[DEPRECATED] Legal actor registration",
|
||||||
|
description:
|
||||||
|
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||||
|
})
|
||||||
@ApiBody({ type: LegalRegisterDto })
|
@ApiBody({ type: LegalRegisterDto })
|
||||||
async registerLegal(@Body() body: LegalRegisterDto) {
|
async registerLegal(@Body() body: LegalRegisterDto) {
|
||||||
return await this.actorAuthService.legalRegister(body);
|
return await this.actorAuthService.legalRegister(body);
|
||||||
@@ -64,16 +136,86 @@ export class ActorAuthController {
|
|||||||
return await this.actorAuthService.createFieldExpertMock(body);
|
return await this.actorAuthService.createFieldExpertMock(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mock: create a registrar for testing. Make private later. */
|
||||||
|
@Post("create-registrar")
|
||||||
|
@ApiBody({ type: CreateRegistrarDto })
|
||||||
|
@ApiAcceptedResponse()
|
||||||
|
async createRegistrar(@Body() body: CreateRegistrarDto) {
|
||||||
|
return await this.actorAuthService.createRegistrarMock(body);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(LocalActorAuthGuard)
|
@UseGuards(LocalActorAuthGuard)
|
||||||
@Post("login")
|
@Post("login")
|
||||||
@Roles()
|
@Roles()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Actor login (returns access + refresh tokens)",
|
||||||
|
description:
|
||||||
|
'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with password and one of `username` / `email` / `nationalCode`. On success the response contains the JWT pair and the resolved profile.',
|
||||||
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: LoginActorDto,
|
type: LoginActorDto,
|
||||||
description: "user verify otp -- call this api and get a tokens",
|
description:
|
||||||
|
"Login payload. Pick one of the swagger examples below to see the exact body shape per role.",
|
||||||
|
examples: {
|
||||||
|
company: {
|
||||||
|
summary: "Insurer / company portal",
|
||||||
|
description:
|
||||||
|
"Sample tenant credentials for the insurer (company) panel.",
|
||||||
|
value: {
|
||||||
|
role: "company",
|
||||||
|
username: "saman_insurer@gmail.com",
|
||||||
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
captcha: "a7bx2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expert: {
|
||||||
|
summary: "Blame expert panel",
|
||||||
|
description: "Sample credentials for a blame (`expert`) account.",
|
||||||
|
value: {
|
||||||
|
role: "expert",
|
||||||
|
username: "blame@gmail.com",
|
||||||
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
captcha: "a7bx2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
damage_expert: {
|
||||||
|
summary: "Damage expert (claim) panel",
|
||||||
|
description: "Sample credentials for a damage-expert account.",
|
||||||
|
value: {
|
||||||
|
role: "damage_expert",
|
||||||
|
username: "claim@gmail.com",
|
||||||
|
password: "123321",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
captcha: "a7bx2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
field_expert: {
|
||||||
|
summary: "Field expert panel",
|
||||||
|
description:
|
||||||
|
"Login with email+password or nationalCode+password for seeded Parsian field experts.",
|
||||||
|
value: {
|
||||||
|
role: "field_expert",
|
||||||
|
nationalCode: "0051967839",
|
||||||
|
password: "Parsian@724",
|
||||||
|
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
captcha: "a7bx2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: "No actor account exists for the given email and role",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: "Wrong password or role mismatch",
|
||||||
})
|
})
|
||||||
@ApiAcceptedResponse()
|
@ApiAcceptedResponse()
|
||||||
async login(@Body() body, @Req() req, @ClientKey() client) {
|
async login(@Req() req: { user: Record<string, unknown> }) {
|
||||||
return await this.actorAuthService.loginActors(req.user);
|
return req.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("forget-password")
|
@Post("forget-password")
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ export class UserAuthController {
|
|||||||
@Post("/send-otp")
|
@Post("/send-otp")
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: UserLoginDto,
|
type: UserLoginDto,
|
||||||
description: "user login api -- call this api and send otp",
|
description: "Users can ask for OTP via this API and receive it",
|
||||||
})
|
})
|
||||||
@ApiAcceptedResponse()
|
@ApiAcceptedResponse()
|
||||||
async sendOtpRq(@Body() body: UserLoginDto) {
|
async sendOtpRq(@Body() body: UserLoginDto) {
|
||||||
const res = await this.userAuthService.sendOtpRequest(body.mobile);
|
const res = await this.userAuthService.sendOtpRequest(body.mobile, {
|
||||||
|
linkToken: body.linkToken,
|
||||||
|
linkContext: body.linkContext,
|
||||||
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
throw new HttpException(res, HttpStatus.ACCEPTED);
|
throw new HttpException(res, HttpStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
@@ -36,7 +40,8 @@ export class UserAuthController {
|
|||||||
@UseGuards(LocalUserAuthGuard)
|
@UseGuards(LocalUserAuthGuard)
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: UserVerifyOtp,
|
type: UserVerifyOtp,
|
||||||
description: "user verify otp -- call this api and get a tokens",
|
description:
|
||||||
|
"Users can send their credentials and get their access token to server",
|
||||||
})
|
})
|
||||||
@ApiAcceptedResponse()
|
@ApiAcceptedResponse()
|
||||||
async login(@Body() body, @Req() req, @CurrentUser() user) {
|
async login(@Body() body, @Req() req, @CurrentUser() user) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
RegisterDtoRs,
|
RegisterDtoRs,
|
||||||
} from "src/auth/dto/actor/register.actor.dto";
|
} from "src/auth/dto/actor/register.actor.dto";
|
||||||
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
||||||
|
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
@@ -25,9 +26,9 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
|
|||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||||
|
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
// import { MailService } from "src/utils/mail/mail.service";
|
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||||
import { OtpService } from "src/utils/otp/otp.service";
|
|
||||||
|
|
||||||
function pick(obj: Record<string, any>, keys: string[]) {
|
function pick(obj: Record<string, any>, keys: string[]) {
|
||||||
const out: Record<string, any> = {};
|
const out: Record<string, any> = {};
|
||||||
@@ -46,10 +47,11 @@ export class ActorAuthService {
|
|||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||||
|
private readonly registrarDbService: RegistrarDbService,
|
||||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
|
||||||
private readonly clientDbService: ClientDbService,
|
private readonly clientDbService: ClientDbService,
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpGeneratorService,
|
||||||
|
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// TODO convrt to class for dynamic controller
|
// TODO convrt to class for dynamic controller
|
||||||
@@ -61,7 +63,7 @@ export class ActorAuthService {
|
|||||||
res = await this.expertDbService.findOne({
|
res = await this.expertDbService.findOne({
|
||||||
_id: new Types.ObjectId(userId),
|
_id: new Types.ObjectId(userId),
|
||||||
});
|
});
|
||||||
else res = await this.expertDbService.findOne({ email: username });
|
else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
|
||||||
break;
|
break;
|
||||||
case RoleEnum.DAMAGE_EXPERT:
|
case RoleEnum.DAMAGE_EXPERT:
|
||||||
if (username == null && userId)
|
if (username == null && userId)
|
||||||
@@ -69,7 +71,10 @@ export class ActorAuthService {
|
|||||||
_id: new Types.ObjectId(userId),
|
_id: new Types.ObjectId(userId),
|
||||||
});
|
});
|
||||||
else
|
else
|
||||||
res = await this.damageExpertDbService.findOne({ email: username });
|
res = await this.findActorByLoginIdentifier(
|
||||||
|
this.damageExpertDbService,
|
||||||
|
username,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case RoleEnum.FIELD_EXPERT:
|
case RoleEnum.FIELD_EXPERT:
|
||||||
if (username == null && userId)
|
if (username == null && userId)
|
||||||
@@ -77,7 +82,14 @@ export class ActorAuthService {
|
|||||||
_id: new Types.ObjectId(userId),
|
_id: new Types.ObjectId(userId),
|
||||||
});
|
});
|
||||||
else
|
else
|
||||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
|
||||||
|
break;
|
||||||
|
case RoleEnum.REGISTRAR:
|
||||||
|
if (username == null && userId)
|
||||||
|
res = await this.registrarDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(userId),
|
||||||
|
});
|
||||||
|
else res = await this.registrarDbService.findOne({ email: username });
|
||||||
break;
|
break;
|
||||||
case RoleEnum.COMPANY:
|
case RoleEnum.COMPANY:
|
||||||
res = await this.insurerExpertDbService.findOne({ email: username });
|
res = await this.insurerExpertDbService.findOne({ email: username });
|
||||||
@@ -88,48 +100,113 @@ export class ActorAuthService {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateActor(username: string, pass: string, role): Promise<any> {
|
/** Normalizes `role` from login body (string or single-element array). */
|
||||||
const user = await this.dynamicDbController(role, username);
|
parseActorLoginRole(role: unknown): RoleEnum {
|
||||||
if (user) {
|
const raw = Array.isArray(role) ? role[0] : role;
|
||||||
if (user.role !== role) {
|
if (
|
||||||
throw new UnauthorizedException("user not assigned to this role");
|
typeof raw !== "string" ||
|
||||||
}
|
!Object.values(RoleEnum).includes(raw as RoleEnum)
|
||||||
if (!(await this.hashService.compare(pass, user.password))) {
|
) {
|
||||||
throw new UnauthorizedException(
|
throw new BadRequestException(
|
||||||
"password is incorrect or access Denied",
|
`Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return raw as RoleEnum;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginActors(user: any) {
|
parseActorLoginUsername(body: Record<string, unknown>): string {
|
||||||
let foundedUser = await this.dynamicDbController(user.role, user.username);
|
const username = body?.username ?? body?.email ?? body?.nationalCode;
|
||||||
if (foundedUser) {
|
if (typeof username !== "string" || !username.trim()) {
|
||||||
const payload = {
|
throw new BadRequestException(
|
||||||
username: foundedUser.username || foundedUser.email,
|
"username, email, or nationalCode is required",
|
||||||
sub: foundedUser._id,
|
);
|
||||||
fullName:
|
|
||||||
`${foundedUser.firstName || ""} ${foundedUser.lastName || ""}`.trim(),
|
|
||||||
role: foundedUser.role || "User",
|
|
||||||
userType: foundedUser.userType || "UserType",
|
|
||||||
clientKey: foundedUser.clientKey || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const accToken = this.jwtService.sign(payload, {
|
|
||||||
secret: `${process.env.SECRET}`,
|
|
||||||
expiresIn: "1h",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
access_token: accToken,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new UnauthorizedException("expert or damage_expert not found");
|
|
||||||
}
|
}
|
||||||
|
return username.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findActorByLoginIdentifier(
|
||||||
|
dbService: { findOne: (filter: any) => Promise<any> },
|
||||||
|
identifier: string,
|
||||||
|
) {
|
||||||
|
const id = identifier.trim();
|
||||||
|
const or: Record<string, string>[] = [{ email: id }, { username: id }];
|
||||||
|
if (/^\d{10}$/.test(id)) {
|
||||||
|
or.push({ nationalCode: id });
|
||||||
|
}
|
||||||
|
return dbService.findOne({ $or: or });
|
||||||
|
}
|
||||||
|
|
||||||
|
issueActorTokens(actor: {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
role?: string;
|
||||||
|
userType?: string;
|
||||||
|
clientKey?: Types.ObjectId | string | null;
|
||||||
|
}) {
|
||||||
|
const payload = {
|
||||||
|
username:
|
||||||
|
actor.username || actor.email || (actor as any).nationalCode || null,
|
||||||
|
sub: actor._id,
|
||||||
|
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||||
|
role: actor.role || "User",
|
||||||
|
userType: actor.userType || "UserType",
|
||||||
|
clientKey: actor.clientKey ? String(actor.clientKey) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const access_token = this.jwtService.sign(payload, {
|
||||||
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
|
expiresIn: "1h",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...payload, access_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateActor(
|
||||||
|
username: string,
|
||||||
|
pass: string,
|
||||||
|
role: RoleEnum,
|
||||||
|
): Promise<any> {
|
||||||
|
const user = await this.dynamicDbController(role, username);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("Actor account not found");
|
||||||
|
}
|
||||||
|
if (user.role !== role) {
|
||||||
|
throw new UnauthorizedException("user not assigned to this role");
|
||||||
|
}
|
||||||
|
if (!(await this.hashService.compare(pass, user.password))) {
|
||||||
|
throw new UnauthorizedException("password is incorrect or access Denied");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates an actor from a login request body (role, username/email, password).
|
||||||
|
*/
|
||||||
|
async loginFromCredentials(body: Record<string, unknown>) {
|
||||||
|
const role = this.parseActorLoginRole(body?.role);
|
||||||
|
const username = this.parseActorLoginUsername(body);
|
||||||
|
const password = body?.password;
|
||||||
|
if (typeof password !== "string" || !password) {
|
||||||
|
throw new BadRequestException("password is required");
|
||||||
|
}
|
||||||
|
const captchaId =
|
||||||
|
typeof body?.captchaId === "string" ? body.captchaId : undefined;
|
||||||
|
const captcha =
|
||||||
|
typeof body?.captcha === "string" ? body.captcha : undefined;
|
||||||
|
await this.captchaChallengeService.verify(captchaId, captcha);
|
||||||
|
const actor = await this.validateActor(username, password, role);
|
||||||
|
return this.issueActorTokens(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
|
||||||
|
async loginActors(user: any) {
|
||||||
|
if (user?.access_token && user?.sub) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
return this.loginFromCredentials(user as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerActors(
|
async registerActors(
|
||||||
@@ -164,7 +241,7 @@ export class ActorAuthService {
|
|||||||
firstName: body.firstName,
|
firstName: body.firstName,
|
||||||
lastName: body.lastName,
|
lastName: body.lastName,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
mobile:body.mobile,
|
mobile: body.mobile,
|
||||||
city: body.city,
|
city: body.city,
|
||||||
state: body.state,
|
state: body.state,
|
||||||
address: body.address,
|
address: body.address,
|
||||||
@@ -273,6 +350,10 @@ export class ActorAuthService {
|
|||||||
actor = await this.fieldExpertDbService.findOne(filter);
|
actor = await this.fieldExpertDbService.findOne(filter);
|
||||||
dbServiceToUpdate = this.fieldExpertDbService as any;
|
dbServiceToUpdate = this.fieldExpertDbService as any;
|
||||||
}
|
}
|
||||||
|
if (!actor) {
|
||||||
|
actor = await this.registrarDbService.findOne(filter);
|
||||||
|
dbServiceToUpdate = this.registrarDbService as any;
|
||||||
|
}
|
||||||
|
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
throw new NotFoundException("actor not found");
|
throw new NotFoundException("actor not found");
|
||||||
@@ -309,6 +390,10 @@ export class ActorAuthService {
|
|||||||
userExist = await this.fieldExpertDbService.findOne({ email });
|
userExist = await this.fieldExpertDbService.findOne({ email });
|
||||||
dbServiceToUpdate = this.fieldExpertDbService;
|
dbServiceToUpdate = this.fieldExpertDbService;
|
||||||
}
|
}
|
||||||
|
if (!userExist) {
|
||||||
|
userExist = await this.registrarDbService.findOne({ email });
|
||||||
|
dbServiceToUpdate = this.registrarDbService as any;
|
||||||
|
}
|
||||||
if (!userExist) throw new NotFoundException("user not found");
|
if (!userExist) throw new NotFoundException("user not found");
|
||||||
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
||||||
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
||||||
@@ -376,13 +461,8 @@ export class ActorAuthService {
|
|||||||
"state",
|
"state",
|
||||||
"address",
|
"address",
|
||||||
],
|
],
|
||||||
field_expert: [
|
field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
|
||||||
"firstName",
|
registrar: ["email"],
|
||||||
"lastName",
|
|
||||||
"email",
|
|
||||||
"phone",
|
|
||||||
"mobile",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedFields = allowedFieldsByRole[role];
|
const allowedFields = allowedFieldsByRole[role];
|
||||||
@@ -407,7 +487,11 @@ export class ActorAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch user detail (document or plain object)
|
// fetch user detail (document or plain object)
|
||||||
const document = await this.dynamicDbController(role, currentUser.role === "company" ? currentUser.username : null, userId);
|
const document = await this.dynamicDbController(
|
||||||
|
role,
|
||||||
|
currentUser.role === "company" ? currentUser.username : null,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!document) throw new NotFoundException("Profile not found");
|
if (!document) throw new NotFoundException("Profile not found");
|
||||||
|
|
||||||
@@ -498,4 +582,34 @@ export class ActorAuthService {
|
|||||||
throw er;
|
throw er;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRegistrarMock(body: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
clientId: string;
|
||||||
|
}) {
|
||||||
|
const hashPassword = await this.hashService.hash(body.password);
|
||||||
|
const payload = {
|
||||||
|
email: body.email.toLowerCase().trim(),
|
||||||
|
password: hashPassword,
|
||||||
|
clientKey: new Types.ObjectId(body.clientId),
|
||||||
|
role: RoleEnum.REGISTRAR,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const created = await this.registrarDbService.create(payload as any);
|
||||||
|
return {
|
||||||
|
id: (created as any)._id,
|
||||||
|
email: (created as any).email,
|
||||||
|
clientKey: (created as any).clientKey,
|
||||||
|
role: (created as any).role,
|
||||||
|
};
|
||||||
|
} catch (er) {
|
||||||
|
if (er.code === 11000) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A registrar with this email already exists.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw er;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/auth/auth-services/captcha-auth.error.ts
Normal file
38
src/auth/auth-services/captcha-auth.error.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
export enum CaptchaAuthErrorCode {
|
||||||
|
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
|
||||||
|
CAPTCHA_NOT_FOUND = "CAPTCHA_NOT_FOUND",
|
||||||
|
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
|
||||||
|
CAPTCHA_INVALID = "CAPTCHA_INVALID",
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: Record<CaptchaAuthErrorCode, string> = {
|
||||||
|
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
|
||||||
|
"Captcha is required. Request a new captcha image first.",
|
||||||
|
[CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND]:
|
||||||
|
"Captcha id was not found. Request a new captcha image.",
|
||||||
|
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
|
||||||
|
"Captcha has expired. Request a new captcha image.",
|
||||||
|
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: messages[code],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
|
||||||
|
if (
|
||||||
|
code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED ||
|
||||||
|
code === CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(captchaAuthErrorBody(code));
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException(captchaAuthErrorBody(code));
|
||||||
|
}
|
||||||
46
src/auth/auth-services/user-auth-error.ts
Normal file
46
src/auth/auth-services/user-auth-error.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
export enum UserAuthErrorCode {
|
||||||
|
USER_NOT_FOUND = "USER_NOT_FOUND",
|
||||||
|
OTP_REQUIRED = "OTP_REQUIRED",
|
||||||
|
OTP_EXPIRED = "OTP_EXPIRED",
|
||||||
|
OTP_INVALID = "OTP_INVALID",
|
||||||
|
OTP_REQUEST_TOO_SOON = "OTP_REQUEST_TOO_SOON",
|
||||||
|
LINK_NOT_FOUND = "LINK_NOT_FOUND",
|
||||||
|
LINK_MOBILE_MISMATCH = "LINK_MOBILE_MISMATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: Record<UserAuthErrorCode, string> = {
|
||||||
|
[UserAuthErrorCode.USER_NOT_FOUND]: "User not found",
|
||||||
|
[UserAuthErrorCode.OTP_REQUIRED]: "Please request an OTP first",
|
||||||
|
[UserAuthErrorCode.OTP_EXPIRED]: "OTP has expired",
|
||||||
|
[UserAuthErrorCode.OTP_INVALID]: "OTP is invalid",
|
||||||
|
[UserAuthErrorCode.OTP_REQUEST_TOO_SOON]:
|
||||||
|
"Wait for expiry time to finish before requesting another OTP",
|
||||||
|
[UserAuthErrorCode.LINK_NOT_FOUND]: "Linked SMS token was not found",
|
||||||
|
[UserAuthErrorCode.LINK_MOBILE_MISMATCH]:
|
||||||
|
"This mobile number is not allowed to use this SMS link",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function userAuthErrorBody(code: UserAuthErrorCode) {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: messages[code],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throwUserAuthError(code: UserAuthErrorCode): never {
|
||||||
|
// if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||||
|
// throw new BadRequestException(userAuthErrorBody(code));
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
|
||||||
|
throw new ForbiddenException(userAuthErrorBody(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException(userAuthErrorBody(code));
|
||||||
|
}
|
||||||
218
src/auth/auth-services/user-link-access.service.ts
Normal file
218
src/auth/auth-services/user-link-access.service.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { Model, Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
UserAuthErrorCode,
|
||||||
|
throwUserAuthError,
|
||||||
|
} from "src/auth/auth-services/user-auth-error";
|
||||||
|
import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema";
|
||||||
|
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
||||||
|
import { normalizeIranMobile } from "src/helpers/iran-mobile";
|
||||||
|
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
|
||||||
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||||
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserLinkAccessService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(RequestManagementModel.name)
|
||||||
|
private readonly requestManagementModel: Model<RequestManagementModel>,
|
||||||
|
@InjectModel(BlameRequest.name)
|
||||||
|
private readonly blameRequestModel: Model<BlameRequest>,
|
||||||
|
@InjectModel(ClaimRequestManagementModel.name)
|
||||||
|
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||||
|
@InjectModel(ClaimCase.name)
|
||||||
|
private readonly claimCaseModel: Model<ClaimCase>,
|
||||||
|
private readonly userDbService: UserDbService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async assertMobileAllowed(params: {
|
||||||
|
mobile: string;
|
||||||
|
linkToken?: string;
|
||||||
|
linkContext?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const linkToken = params.linkToken?.trim();
|
||||||
|
if (!linkToken) return;
|
||||||
|
|
||||||
|
const allowedMobiles = await this.resolveAllowedMobiles(
|
||||||
|
linkToken,
|
||||||
|
params.linkContext,
|
||||||
|
);
|
||||||
|
if (allowedMobiles.length === 0) {
|
||||||
|
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMobile = normalizeIranMobile(params.mobile);
|
||||||
|
if (
|
||||||
|
!normalizedMobile ||
|
||||||
|
!allowedMobiles.some(
|
||||||
|
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveAllowedMobiles(
|
||||||
|
linkToken: string,
|
||||||
|
linkContext?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!Types.ObjectId.isValid(linkToken)) return [];
|
||||||
|
|
||||||
|
const id = new Types.ObjectId(linkToken);
|
||||||
|
const context = this.normalizeContext(linkContext);
|
||||||
|
const allowedMobiles = new Set<string>();
|
||||||
|
|
||||||
|
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
|
||||||
|
await Promise.all([
|
||||||
|
this.requestManagementModel.findById(id).lean().exec(),
|
||||||
|
this.blameRequestModel.findById(id).lean().exec(),
|
||||||
|
this.claimRequestManagementModel.findById(id).lean().exec(),
|
||||||
|
this.claimCaseModel.findById(id).lean().exec(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
|
||||||
|
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
|
||||||
|
await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
|
||||||
|
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
|
||||||
|
|
||||||
|
return Array.from(allowedMobiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addLegacyRequestPhones(
|
||||||
|
allowedMobiles: Set<string>,
|
||||||
|
legacyRequest: any,
|
||||||
|
context?: string,
|
||||||
|
) {
|
||||||
|
if (!legacyRequest) return;
|
||||||
|
|
||||||
|
const shouldAddFirst = !context || this.isFirstContext(context);
|
||||||
|
const shouldAddSecond = !context || this.isSecondContext(context);
|
||||||
|
|
||||||
|
if (shouldAddFirst) {
|
||||||
|
this.addPhone(
|
||||||
|
allowedMobiles,
|
||||||
|
legacyRequest.firstPartyDetails?.firstPartyPhoneNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAddSecond) {
|
||||||
|
this.addPhone(
|
||||||
|
allowedMobiles,
|
||||||
|
legacyRequest.secondPartyDetails?.secondPartyPhoneNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of legacyRequest.history || []) {
|
||||||
|
const metadata = event?.metadata;
|
||||||
|
if (shouldAddSecond) this.addPhone(allowedMobiles, metadata?.secondPartyPhone);
|
||||||
|
for (const sent of metadata?.sentTo || []) {
|
||||||
|
if (!context || this.matchesRoleContext(context, sent?.role)) {
|
||||||
|
this.addPhone(allowedMobiles, sent?.phoneNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addBlameRequestPhones(
|
||||||
|
allowedMobiles: Set<string>,
|
||||||
|
blameRequest: any,
|
||||||
|
context?: string,
|
||||||
|
) {
|
||||||
|
if (!blameRequest) return;
|
||||||
|
|
||||||
|
for (const party of blameRequest.parties || []) {
|
||||||
|
if (context && !this.matchesRoleContext(context, party?.role)) continue;
|
||||||
|
this.addPhone(allowedMobiles, party?.person?.phoneNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addLegacyClaimOwnerPhone(
|
||||||
|
allowedMobiles: Set<string>,
|
||||||
|
claimRequest: any,
|
||||||
|
) {
|
||||||
|
if (!claimRequest) return;
|
||||||
|
|
||||||
|
const ownerUserId = claimRequest.owner?.userId || claimRequest.userId;
|
||||||
|
if (!ownerUserId) return;
|
||||||
|
|
||||||
|
const ownerUserIdText = String(ownerUserId);
|
||||||
|
if (claimRequest.blameRequestId) {
|
||||||
|
const blameRequest = await this.blameRequestModel
|
||||||
|
.findById(claimRequest.blameRequestId)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
const ownerParty = (blameRequest?.parties || []).find(
|
||||||
|
(party: any) =>
|
||||||
|
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
|
||||||
|
);
|
||||||
|
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Types.ObjectId.isValid(ownerUserIdText)) {
|
||||||
|
const user = await this.userDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(ownerUserIdText),
|
||||||
|
});
|
||||||
|
this.addPhone(allowedMobiles, user?.mobile);
|
||||||
|
this.addPhone(allowedMobiles, user?.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V2 `claimCases` — token in `/caseClaim?token=...` SMS links. */
|
||||||
|
private async addClaimCaseOwnerPhone(
|
||||||
|
allowedMobiles: Set<string>,
|
||||||
|
claimCase: any,
|
||||||
|
) {
|
||||||
|
if (!claimCase?.owner?.userId) return;
|
||||||
|
|
||||||
|
const ownerUserIdText = String(claimCase.owner.userId);
|
||||||
|
if (claimCase.blameRequestId) {
|
||||||
|
const blameRequest = await this.blameRequestModel
|
||||||
|
.findById(claimCase.blameRequestId)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
const ownerParty = (blameRequest?.parties || []).find(
|
||||||
|
(party: any) =>
|
||||||
|
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
|
||||||
|
);
|
||||||
|
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Types.ObjectId.isValid(ownerUserIdText)) {
|
||||||
|
const user = await this.userDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(ownerUserIdText),
|
||||||
|
});
|
||||||
|
this.addPhone(allowedMobiles, user?.mobile);
|
||||||
|
this.addPhone(allowedMobiles, user?.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addPhone(allowedMobiles: Set<string>, phone?: string) {
|
||||||
|
const normalized = normalizeIranMobile(phone);
|
||||||
|
if (normalized) allowedMobiles.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeContext(linkContext?: string): string | undefined {
|
||||||
|
const ctx = linkContext?.trim().toUpperCase();
|
||||||
|
if (!ctx) return undefined;
|
||||||
|
if (ctx === "USER" || ctx === "USER1") return "FIRST";
|
||||||
|
if (ctx === "USER2") return "SECOND";
|
||||||
|
if (ctx === "CASECLAIM" || ctx === "CLAIM") return undefined;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesRoleContext(context: string, role?: string): boolean {
|
||||||
|
if (this.isFirstContext(context)) return role === PartyRole.FIRST;
|
||||||
|
if (this.isSecondContext(context)) return role === PartyRole.SECOND;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFirstContext(context: string): boolean {
|
||||||
|
return ["FIRST", "USER", "USER1", "FIRST_PARTY"].includes(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSecondContext(context: string): boolean {
|
||||||
|
return ["SECOND", "USER2", "SECOND_PARTY"].includes(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
import {
|
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotAcceptableException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
UserAuthErrorCode,
|
||||||
|
throwUserAuthError,
|
||||||
|
} from "src/auth/auth-services/user-auth-error";
|
||||||
|
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||||
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
||||||
|
import {
|
||||||
|
buildUserLookupByPhone,
|
||||||
|
normalizeIranMobile,
|
||||||
|
} from "src/helpers/iran-mobile";
|
||||||
|
import {
|
||||||
|
computeOtpExpireMs,
|
||||||
|
FAKE_OTP_CODE,
|
||||||
|
isFakeOtpEnabled,
|
||||||
|
isOtpExpiryActive,
|
||||||
|
readOtpExpireMinutesFromEnv,
|
||||||
|
} from "src/helpers/user-otp-expiry";
|
||||||
|
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
import { OtpService } from "src/utils/otp/otp.service";
|
|
||||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
|
||||||
|
|
||||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
export interface LinkBinding {
|
||||||
|
linkToken?: string;
|
||||||
|
linkContext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAuthService {
|
export class UserAuthService {
|
||||||
private readonly logger = new Logger(UserAuthService.name);
|
private readonly logger = new Logger(UserAuthService.name);
|
||||||
@@ -24,18 +36,32 @@ export class UserAuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly userDbService: UserDbService,
|
private readonly userDbService: UserDbService,
|
||||||
private readonly hashService: HashService,
|
private readonly hashService: HashService,
|
||||||
private readonly otpCreator: OtpService,
|
private readonly otpCreator: OtpGeneratorService,
|
||||||
private readonly smsManagerService: SmsManagerService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
|
private readonly userLinkAccessService: UserLinkAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(username: string, pass: string): Promise<any> {
|
async validateUser(
|
||||||
const user = await this.userDbService.findOne({ username });
|
username: string,
|
||||||
if (!user) throw new NotFoundException("user not found");
|
pass: string,
|
||||||
|
binding: LinkBinding = {},
|
||||||
|
): Promise<any> {
|
||||||
|
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
|
||||||
|
|
||||||
const now = new Date().getTime();
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
if (user.otp == null) throw new NotAcceptableException("please get otp");
|
mobile: canonicalMobile,
|
||||||
if (user.otpExpire < now) {
|
linkToken: binding.linkToken,
|
||||||
throw new NotAcceptableException("expire otp");
|
linkContext: binding.linkContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.userDbService.findOne(
|
||||||
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
|
);
|
||||||
|
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||||
|
|
||||||
|
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
|
||||||
|
if (!isOtpExpiryActive(user.otpExpire)) {
|
||||||
|
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
||||||
}
|
}
|
||||||
if (await this.hashService.compare(pass, user.otp)) {
|
if (await this.hashService.compare(pass, user.otp)) {
|
||||||
return user;
|
return user;
|
||||||
@@ -44,39 +70,59 @@ export class UserAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(user: any) {
|
async login(user: any) {
|
||||||
|
const userId = String(user._id ?? user.id ?? "");
|
||||||
const payload = {
|
const payload = {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
sub: user.id,
|
sub: userId,
|
||||||
role: "user",
|
role: "user",
|
||||||
};
|
};
|
||||||
const accToken = this.jwtService.sign(payload, {
|
const accToken = this.jwtService.sign(payload, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
});
|
});
|
||||||
await this.userDbService.findOneAndUpdate(
|
await this.userDbService.findOneAndUpdate(
|
||||||
{ username: user.username },
|
{ username: user.username },
|
||||||
{
|
{
|
||||||
tokens: { token: accToken },
|
tokens: { token: accToken },
|
||||||
otp: null,
|
otp: null,
|
||||||
|
otpExpire: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
userId: user._id,
|
userId,
|
||||||
access_token: accToken,
|
access_token: accToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> {
|
async sendOtpRequest(
|
||||||
const userExist = await this.userDbService.findOne({
|
mobile: string,
|
||||||
mobile,
|
binding: LinkBinding = {},
|
||||||
|
): Promise<LoginDtoRs> {
|
||||||
|
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
|
||||||
|
if (!canonicalMobile) {
|
||||||
|
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
|
mobile: canonicalMobile,
|
||||||
|
linkToken: binding.linkToken,
|
||||||
|
linkContext: binding.linkContext,
|
||||||
});
|
});
|
||||||
const otp = this.otpCreator.create();
|
|
||||||
|
const userExist = await this.userDbService.findOne(
|
||||||
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
|
);
|
||||||
|
const otp = this.createOtpForRequest();
|
||||||
const hashOtp = await this.hashService.hash(otp);
|
const hashOtp = await this.hashService.hash(otp);
|
||||||
|
const expireMinutes = readOtpExpireMinutesFromEnv();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
|
||||||
|
|
||||||
if (!userExist) {
|
if (!userExist) {
|
||||||
await this.smsSender(otp, mobile);
|
await this.smsSender(otp, canonicalMobile);
|
||||||
/// create otp request
|
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||||
const newUser = await this.userDbService.createUser({
|
const newUser = await this.userDbService.createUser({
|
||||||
mobile,
|
mobile: canonicalMobile,
|
||||||
username: mobile,
|
username: canonicalMobile,
|
||||||
otp: hashOtp,
|
otp: hashOtp,
|
||||||
tokens: {
|
tokens: {
|
||||||
token: "",
|
token: "",
|
||||||
@@ -90,50 +136,57 @@ export class UserAuthService {
|
|||||||
city: "",
|
city: "",
|
||||||
address: "",
|
address: "",
|
||||||
state: "",
|
state: "",
|
||||||
otpExpire: new Date(
|
otpExpire,
|
||||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
|
||||||
).getTime(),
|
|
||||||
});
|
});
|
||||||
return new LoginDtoRs(newUser);
|
return new LoginDtoRs(newUser);
|
||||||
}
|
}
|
||||||
if (userExist) {
|
|
||||||
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish");
|
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
|
||||||
await this.smsSender(otp, mobile);
|
// throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
||||||
const updateTokens = await this.userDbService.findOneAndUpdate(
|
return new LoginDtoRs(userExist, "OTP Still valid");
|
||||||
{
|
|
||||||
username: userExist.username,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
otp: hashOtp,
|
|
||||||
otpExpire: new Date(
|
|
||||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
|
||||||
).getTime(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (updateTokens) return new LoginDtoRs(userExist);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.smsSender(otp, canonicalMobile);
|
||||||
|
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||||
|
await this.userDbService.findOneAndUpdate(
|
||||||
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
|
{
|
||||||
|
otp: hashOtp,
|
||||||
|
otpExpire,
|
||||||
|
mobile: canonicalMobile,
|
||||||
|
username: userExist.username || canonicalMobile,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new LoginDtoRs(userExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOtpForRequest(): string {
|
||||||
|
if (isFakeOtpEnabled()) {
|
||||||
|
this.logger.warn(
|
||||||
|
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
|
||||||
|
);
|
||||||
|
return FAKE_OTP_CODE;
|
||||||
|
}
|
||||||
|
return this.otpCreator.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async smsSender(otp: string, mobile: string) {
|
private async smsSender(otp: string, mobile: string) {
|
||||||
return this.smsManagerService
|
if (isFakeOtpEnabled()) {
|
||||||
.verifyLookUp({
|
this.logger.log(
|
||||||
token: otp,
|
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
|
||||||
template: process.env.AUTH_SMS_TEMPLATE,
|
);
|
||||||
receptor: mobile,
|
return;
|
||||||
})
|
}
|
||||||
.then((smsRes) => {
|
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||||
this.logger.log(
|
mobile,
|
||||||
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
|
otp,
|
||||||
);
|
process.env.AUTH_SMS_TEMPLATE,
|
||||||
})
|
);
|
||||||
.catch((er) => {
|
if (!ok) {
|
||||||
this.logger.error(
|
throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
|
||||||
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
|
}
|
||||||
);
|
this.logger.log(
|
||||||
throw new HttpException(
|
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||||
" auth sms send failed",
|
);
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
|
import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
|
||||||
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
|
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
|
||||||
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
|
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
|
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||||
|
import {
|
||||||
|
ClaimCase,
|
||||||
|
ClaimCaseSchema,
|
||||||
|
} from "src/claim-request-management/entites/schema/claim-cases.schema";
|
||||||
|
import {
|
||||||
|
ClaimRequestManagementModel,
|
||||||
|
ClaimRequestManagementSchema,
|
||||||
|
} from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
||||||
import { ClientModule } from "src/client/client.module";
|
import { ClientModule } from "src/client/client.module";
|
||||||
|
import {
|
||||||
|
BlameRequest,
|
||||||
|
BlameRequestSchema,
|
||||||
|
} from "src/request-management/entities/schema/blame-cases.schema";
|
||||||
|
import {
|
||||||
|
RequestManagementModel,
|
||||||
|
RequestManagementSchema,
|
||||||
|
} from "src/request-management/entities/schema/request-management.schema";
|
||||||
import { UsersModule } from "src/users/users.module";
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { HashModule } from "src/utils/hash/hash.module";
|
import { HashModule } from "src/utils/hash/hash.module";
|
||||||
// import { MailModule } from "src/utils/mail/mail.module";
|
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||||
import { OtpModule } from "src/utils/otp/otp.module";
|
import { CaptchaModule } from "src/captcha/captcha.module";
|
||||||
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
|
||||||
|
|
||||||
|
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// MailModule, // Mailer disabled – not used
|
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
HashModule,
|
HashModule,
|
||||||
OtpModule,
|
CaptchaModule,
|
||||||
PassportModule,
|
SmsOrchestrationModule,
|
||||||
SmsManagerModule,
|
MongooseModule.forFeature([
|
||||||
|
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
|
||||||
|
{ name: BlameRequest.name, schema: BlameRequestSchema },
|
||||||
|
{
|
||||||
|
name: ClaimRequestManagementModel.name,
|
||||||
|
schema: ClaimRequestManagementSchema,
|
||||||
|
},
|
||||||
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
|
]),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
signOptions: { expiresIn: "1h" },
|
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
|
||||||
global: true,
|
global: true,
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
|
UserLinkAccessService,
|
||||||
ActorAuthService,
|
ActorAuthService,
|
||||||
LocalStrategy,
|
|
||||||
LocalActorStrategy,
|
|
||||||
JwtService,
|
JwtService,
|
||||||
|
LocalActorAuthGuard,
|
||||||
|
LocalUserAuthGuard,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
UserAuthService,
|
||||||
|
ActorAuthService,
|
||||||
|
JwtService,
|
||||||
|
LocalActorAuthGuard,
|
||||||
|
LocalUserAuthGuard,
|
||||||
],
|
],
|
||||||
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
|
|
||||||
controllers: [UserAuthController, ActorAuthController],
|
controllers: [UserAuthController, ActorAuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsMongoId, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateRegistrarDto {
|
||||||
|
@ApiProperty({ example: "registrar@sample.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "securePassword123", minLength: 6 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||||
|
@IsMongoId()
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,55 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
export class LoginActorDto {
|
export class LoginActorDto {
|
||||||
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
|
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
|
||||||
role: RoleEnum[];
|
role: RoleEnum[];
|
||||||
|
|
||||||
@ApiProperty({})
|
@ApiPropertyOptional({
|
||||||
username: string;
|
description:
|
||||||
|
"Actor email or username. For field experts you may also send nationalCode instead.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Alias for username when logging in with email.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "4311402422",
|
||||||
|
description:
|
||||||
|
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalCode?: string;
|
||||||
|
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
description: "Captcha id from GET /actor/captcha.",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
captchaId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: "a7bx2",
|
||||||
|
description: "Characters shown in the captcha image.",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(16)
|
||||||
|
captcha: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginActorDtoRs extends LoginActorDto {
|
export class LoginActorDtoRs extends LoginActorDto {
|
||||||
|
|||||||
28
src/auth/dto/captcha-response.dto.ts
Normal file
28
src/auth/dto/captcha-response.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export class CaptchaResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Captcha challenge id — send back with POST /actor/login.",
|
||||||
|
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
|
})
|
||||||
|
captchaId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Sample text",
|
||||||
|
example: "sb20xe"
|
||||||
|
})
|
||||||
|
text: string
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
|
||||||
|
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
|
||||||
|
})
|
||||||
|
image: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Unix timestamp (ms) when this captcha expires.",
|
||||||
|
example: 1710000000000,
|
||||||
|
})
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
export class GetCaptchaImageQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["json", "raw"],
|
||||||
|
default: "json",
|
||||||
|
description:
|
||||||
|
"On GET /actor/captcha, use `raw` to return image/svg+xml (for browser preview). " +
|
||||||
|
"The JSON response includes `captchaId` either way.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["json", "raw"])
|
||||||
|
format?: "json" | "raw";
|
||||||
|
}
|
||||||
@@ -1,17 +1,43 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
import { UserModel } from "src/users/entities/schema/user.schema";
|
import { UserModel } from "src/users/entities/schema/user.schema";
|
||||||
|
|
||||||
export class UserLoginDto {
|
export class UserLoginDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "09226187419",
|
example: "09226187419",
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "User login dto",
|
description: "Mobile number (username for OTP login)",
|
||||||
})
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||||
|
type: "string",
|
||||||
|
description: "Raw token from linked SMS URL (?token=...).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
linkToken?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "FIRST",
|
||||||
|
type: "string",
|
||||||
|
description: "Optional route/context hint for linked SMS login.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
linkContext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDtoRs extends UserModel {
|
export class LoginDtoRs extends UserModel {
|
||||||
|
@ApiProperty({ type: "string" })
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "09226187419",
|
example: "09226187419",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -37,8 +63,10 @@ export class LoginDtoRs extends UserModel {
|
|||||||
username: string;
|
username: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
nationalCode: string;
|
nationalCode: string;
|
||||||
constructor(loginData) {
|
|
||||||
|
constructor(loginData, message: string = "") {
|
||||||
super();
|
super();
|
||||||
this.mobile = loginData.mobile;
|
this.mobile = loginData.mobile;
|
||||||
|
this.message = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class UserVerifyOtp {
|
export class UserVerifyOtp {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "09226187419",
|
example: "09226187419",
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "User login dto",
|
description: "Mobile number (same value sent to send-otp)",
|
||||||
})
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: "258567",
|
example: "258567",
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "User login verify dto",
|
description: "OTP code from SMS",
|
||||||
})
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(16)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||||
|
type: "string",
|
||||||
|
description: "Raw token from linked SMS URL (?token=...).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
linkToken?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "FIRST",
|
||||||
|
type: "string",
|
||||||
|
description: "Optional route/context hint for linked SMS login.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
linkContext?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
import {
|
import {
|
||||||
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalActorAuthGuard extends AuthGuard("actor") {
|
export class LocalActorAuthGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly actorAuthService: ActorAuthService,
|
private readonly actorAuthService: ActorAuthService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
) {
|
) {}
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
const path = request.url;
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (path === "/actor/login") {
|
if (this.isActorLoginRequest(request)) {
|
||||||
const loginData = await this.actorAuthService.loginActors(request.body);
|
const loginData = await this.actorAuthService.loginFromCredentials(
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
request.user = loginData;
|
request.user = loginData;
|
||||||
request.identity = request;
|
request.identity = loginData;
|
||||||
return true;
|
return true;
|
||||||
} else {
|
|
||||||
throw new UnauthorizedException("Token not found");
|
|
||||||
}
|
}
|
||||||
|
throw new UnauthorizedException("Token not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -44,6 +42,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
|||||||
RoleEnum.DAMAGE_EXPERT,
|
RoleEnum.DAMAGE_EXPERT,
|
||||||
RoleEnum.COMPANY,
|
RoleEnum.COMPANY,
|
||||||
RoleEnum.FIELD_EXPERT,
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.REGISTRAR,
|
||||||
].includes(payload.role)
|
].includes(payload.role)
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException("User role is not authorized");
|
throw new UnauthorizedException("User role is not authorized");
|
||||||
@@ -58,9 +57,21 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromHeader(request: Request): string | undefined {
|
private isActorLoginRequest(request: {
|
||||||
//@ts-ignore
|
url?: string;
|
||||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
path?: string;
|
||||||
|
route?: { path?: string };
|
||||||
|
}): boolean {
|
||||||
|
const path = (request.route?.path ?? request.url ?? request.path ?? "")
|
||||||
|
.split("?")[0]
|
||||||
|
.replace(/\/+$/, "");
|
||||||
|
return path === "/actor/login" || path.endsWith("/actor/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: {
|
||||||
|
headers?: { authorization?: string };
|
||||||
|
}): string | undefined {
|
||||||
|
const [type, token] = request.headers?.authorization?.split(" ") ?? [];
|
||||||
return type === "Bearer" ? token : undefined;
|
return type === "Bearer" ? token : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow users to pass through (they will be checked by service methods)
|
// Allow users to pass through (they will be checked by service methods)
|
||||||
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
|
|||||||
|
|
||||||
throw new UnauthorizedException("Invalid role");
|
throw new UnauthorizedException("Invalid role");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
|
if (
|
||||||
|
error instanceof ForbiddenException ||
|
||||||
|
error instanceof UnauthorizedException
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
|
|||||||
return type === "Bearer" ? token : undefined;
|
return type === "Bearer" ? token : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,31 +8,42 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
|
const GLOBAL_GUARD_ROLES = new Set<string>([
|
||||||
|
RoleEnum.USER,
|
||||||
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.REGISTRAR,
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GlobalGuard implements CanActivate {
|
export class GlobalGuard implements CanActivate {
|
||||||
constructor(private readonly jwtService: JwtService) {}
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException("Missing Bearer token");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
});
|
});
|
||||||
if (payload.role !== RoleEnum.USER) {
|
|
||||||
console.log(
|
if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) {
|
||||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
throw new UnauthorizedException(
|
||||||
request.user.role,
|
`Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`,
|
||||||
);
|
);
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.user = payload;
|
request.user = payload;
|
||||||
request.identity = request.user;
|
request.identity = request.user;
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new UnauthorizedException();
|
if (error instanceof UnauthorizedException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException("Invalid or expired token");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { Reflector } from "@nestjs/core";
|
|||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
constructor(private readonly reflector: Reflector) {}
|
constructor(private readonly reflector: Reflector) {}
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
// get the roles required
|
|
||||||
const roles = this.reflector.getAllAndOverride<string[]>("role", [
|
const roles = this.reflector.getAllAndOverride<string[]>("role", [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (!roles) {
|
if (!roles?.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const userRoles = request.user?.role?.split(",");
|
const role = request.user?.role;
|
||||||
|
if (!role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const userRoles = String(role).split(",");
|
||||||
return this.validateRoles(roles, userRoles);
|
return this.validateRoles(roles, userRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/auth/guards/settings-jwt.guard.ts
Normal file
41
src/auth/guards/settings-jwt.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies Bearer JWT for platform settings routes. Does not restrict by role;
|
||||||
|
* pair with {@link RolesGuard} on handlers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsJwtGuard implements CanActivate {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException("Token not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
|
});
|
||||||
|
(request as any).user = payload;
|
||||||
|
(request as any).identity = payload;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("Invalid token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||||
|
return type === "Bearer" ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotAcceptableException,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import {
|
||||||
|
UserAuthErrorCode,
|
||||||
|
throwUserAuthError,
|
||||||
|
} from "src/auth/auth-services/user-auth-error";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalUserAuthGuard extends AuthGuard("local") {
|
export class LocalUserAuthGuard implements CanActivate {
|
||||||
constructor(private readonly userAuthService: UserAuthService) {
|
constructor(private readonly userAuthService: UserAuthService) {}
|
||||||
super();
|
|
||||||
}
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const { username, password } = request.body;
|
const { username, password, linkToken, linkContext } = request.body ?? {};
|
||||||
let isValidUser = await this.userAuthService.validateUser(
|
const isValidUser = await this.userAuthService.validateUser(
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
{ linkToken, linkContext },
|
||||||
);
|
);
|
||||||
if (!isValidUser) {
|
if (!isValidUser) {
|
||||||
throw new NotAcceptableException("otp is wrong");
|
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||||
}
|
}
|
||||||
request["user"] = isValidUser;
|
request.user = isValidUser;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
|
||||||
import { Strategy } from "passport-local";
|
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LocalActorStrategy extends PassportStrategy(Strategy, "actor") {
|
|
||||||
constructor(private readonly actorAuthService: ActorAuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
// async validate(username, password): Promise<any> {
|
|
||||||
// const user = await this.actorAuthService.validateActor(
|
|
||||||
// username,
|
|
||||||
// password,
|
|
||||||
// );
|
|
||||||
// if (!user) {
|
|
||||||
// throw new UnauthorizedException("user not found");
|
|
||||||
// }
|
|
||||||
// return user;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
|
||||||
import { Strategy } from "passport-local";
|
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(private readonly userAuthService: UserAuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(username: string, password: string): Promise<any> {
|
|
||||||
const user = await this.userAuthService.validateUser(username, password);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException("user not found please register");
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
100
src/captcha/captcha-challenge.service.ts
Normal file
100
src/captcha/captcha-challenge.service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
CaptchaAuthErrorCode,
|
||||||
|
throwCaptchaAuthError,
|
||||||
|
} from "src/auth/auth-services/captcha-auth.error";
|
||||||
|
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||||
|
import { CaptchaService } from "src/captcha/captcha.service";
|
||||||
|
import { CaptchaChallengeDbService } from "src/captcha/entities/db-service/captcha-challenge.db.service";
|
||||||
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaChallengeService {
|
||||||
|
private readonly isDev: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly captchaService: CaptchaService,
|
||||||
|
private readonly hashService: HashService,
|
||||||
|
private readonly captchaChallengeDbService: CaptchaChallengeDbService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.isDev = this.configService.get<string>("NODE_ENV") === "development";
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue(): Promise<CaptchaResponseDto> {
|
||||||
|
const generated = this.captchaService.generate();
|
||||||
|
const captchaId = randomUUID();
|
||||||
|
|
||||||
|
// Always hash and persist the answer — the env check was the root bug
|
||||||
|
const answerHash = await this.hashService.hash(
|
||||||
|
this.captchaService.normalizeAnswer(generated.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.captchaChallengeDbService.create({
|
||||||
|
captchaId,
|
||||||
|
answerHash,
|
||||||
|
image: generated.image,
|
||||||
|
expiresAt: generated.expiresAt,
|
||||||
|
expireAt: new Date(generated.expiresAt),
|
||||||
|
usedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
captchaId,
|
||||||
|
image: generated.image,
|
||||||
|
expiresAt: generated.expiresAt,
|
||||||
|
...(this.isDev && { text: generated.text }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getImageById(captchaId: string): Promise<string> {
|
||||||
|
const challenge =
|
||||||
|
await this.captchaChallengeDbService.findByCaptchaId(captchaId);
|
||||||
|
if (!challenge) {
|
||||||
|
throw new NotFoundException("Captcha not found");
|
||||||
|
}
|
||||||
|
return this.decodeImage(challenge.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(
|
||||||
|
captchaId: string | undefined,
|
||||||
|
answer: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!captchaId?.trim()) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||||
|
}
|
||||||
|
if (!answer?.trim()) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = await this.captchaChallengeDbService.findByCaptchaId(
|
||||||
|
captchaId.trim(),
|
||||||
|
);
|
||||||
|
if (!challenge) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (challenge.usedAt) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
||||||
|
}
|
||||||
|
if (challenge.expiresAt < Date.now()) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await this.hashService.compare(
|
||||||
|
this.captchaService.normalizeAnswer(answer),
|
||||||
|
challenge.answerHash,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.captchaChallengeDbService.markUsed(challenge.captchaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeImage(imageDataUri: string): string {
|
||||||
|
const base64 = imageDataUri.replace(/^data:image\/svg\+xml;base64,/, "");
|
||||||
|
return Buffer.from(base64, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/captcha/captcha.module.ts
Normal file
28
src/captcha/captcha.module.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { HashModule } from "src/utils/hash/hash.module";
|
||||||
|
import { CaptchaChallengeService } from "./captcha-challenge.service";
|
||||||
|
import { CaptchaService } from "./captcha.service";
|
||||||
|
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
|
||||||
|
import {
|
||||||
|
CaptchaChallenge,
|
||||||
|
CaptchaChallengeSchema,
|
||||||
|
} from "./entities/schema/captcha-challenge.schema";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
HashModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CaptchaService,
|
||||||
|
CaptchaChallengeDbService,
|
||||||
|
CaptchaChallengeService,
|
||||||
|
],
|
||||||
|
exports: [CaptchaChallengeService],
|
||||||
|
})
|
||||||
|
export class CaptchaModule {}
|
||||||
43
src/captcha/captcha.service.ts
Normal file
43
src/captcha/captcha.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import * as svgCaptcha from "svg-captcha";
|
||||||
|
|
||||||
|
export interface GeneratedCaptcha {
|
||||||
|
text: string;
|
||||||
|
image: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaService {
|
||||||
|
generate(): GeneratedCaptcha {
|
||||||
|
const captcha = svgCaptcha.create({
|
||||||
|
size: 5,
|
||||||
|
ignoreChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||||
|
noise: 1,
|
||||||
|
color: false,
|
||||||
|
background: "#f8fafc",
|
||||||
|
width: 160,
|
||||||
|
height: 56,
|
||||||
|
fontSize: 52,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: captcha.text,
|
||||||
|
image: `data:image/svg+xml;base64,${Buffer.from(captcha.data, "utf8").toString("base64")}`,
|
||||||
|
expiresAt: this.buildExpireAt(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAnswer(input: string): string {
|
||||||
|
return input.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildExpireAt(): number {
|
||||||
|
const raw = Number(
|
||||||
|
process.env.EXP_CAPTCHA_TIME ?? process.env.EXP_OTP_TIME ?? "2",
|
||||||
|
);
|
||||||
|
const minutes = Number.isFinite(raw) && raw > 0 ? raw : 2;
|
||||||
|
return Date.now() + minutes * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
CaptchaChallenge,
|
||||||
|
CaptchaChallengeDocument,
|
||||||
|
} from "../schema/captcha-challenge.schema";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaChallengeDbService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(CaptchaChallenge.name)
|
||||||
|
private readonly captchaChallengeModel: Model<CaptchaChallengeDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(data: CaptchaChallenge): Promise<CaptchaChallengeDocument> {
|
||||||
|
return this.captchaChallengeModel.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByCaptchaId(
|
||||||
|
captchaId: string,
|
||||||
|
): Promise<CaptchaChallengeDocument | null> {
|
||||||
|
return this.captchaChallengeModel.findOne({ captchaId });
|
||||||
|
}
|
||||||
|
|
||||||
|
markUsed(captchaId: string): Promise<CaptchaChallengeDocument | null> {
|
||||||
|
return this.captchaChallengeModel.findOneAndUpdate(
|
||||||
|
{ captchaId, usedAt: null },
|
||||||
|
{ $set: { usedAt: new Date() } },
|
||||||
|
{ new: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument } from "mongoose";
|
||||||
|
|
||||||
|
@Schema({
|
||||||
|
collection: "captcha-challenges",
|
||||||
|
timestamps: true,
|
||||||
|
versionKey: false,
|
||||||
|
})
|
||||||
|
export class CaptchaChallenge {
|
||||||
|
@Prop({ required: true, unique: true, index: true })
|
||||||
|
captchaId: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
answerHash?: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
image: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
expiresAt: number;
|
||||||
|
|
||||||
|
/** Mongo TTL — document removed shortly after this time. */
|
||||||
|
@Prop({ required: true, expires: 0 })
|
||||||
|
expireAt: Date;
|
||||||
|
|
||||||
|
@Prop({ default: null })
|
||||||
|
usedAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CaptchaChallengeDocument = HydratedDocument<CaptchaChallenge>;
|
||||||
|
export const CaptchaChallengeSchema =
|
||||||
|
SchemaFactory.createForClass(CaptchaChallenge);
|
||||||
@@ -22,14 +22,16 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiExcludeController,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
|
||||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
|
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
|
||||||
@@ -38,8 +40,9 @@ import { UserObjectionDto } from "./dto/user-objection.dto";
|
|||||||
import { InPersonVisitDto } from "./dto/in-person-visit.dto";
|
import { InPersonVisitDto } from "./dto/in-person-visit.dto";
|
||||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||||
|
|
||||||
|
@ApiExcludeController()
|
||||||
@Controller("claim-request-management")
|
@Controller("claim-request-management")
|
||||||
@ApiTags("claim-request-management")
|
// @ApiTags("claim-request-management")
|
||||||
@Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||||
@UseGuards(ClaimAccessGuard, RolesGuard)
|
@UseGuards(ClaimAccessGuard, RolesGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -48,7 +51,8 @@ export class ClaimRequestManagementController {
|
|||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiParam({ name: "blameId" })
|
// @ApiParam({ name: "blameId" })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Post("/:blameId")
|
@Post("/:blameId")
|
||||||
async createClaimRequest(
|
async createClaimRequest(
|
||||||
@Param("blameId") requestId: string,
|
@Param("blameId") requestId: string,
|
||||||
@@ -61,9 +65,10 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({ type: CarDamagePartDto })
|
// @ApiBody({ type: CarDamagePartDto })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("/car-part-damage/:claimRequestID")
|
@Patch("/car-part-damage/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
async carPartDamage(
|
async carPartDamage(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@Body() body: CarDamagePartDto,
|
@Body() body: CarDamagePartDto,
|
||||||
@@ -76,6 +81,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("/car-other-part")
|
@Get("/car-other-part")
|
||||||
async getCarOtherParts() {
|
async getCarOtherParts() {
|
||||||
const carOtherPart = await readFile(
|
const carOtherPart = await readFile(
|
||||||
@@ -85,12 +91,12 @@ export class ClaimRequestManagementController {
|
|||||||
return carOtherPart;
|
return carOtherPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({ type: OtherCarDamagePartDto })
|
// @ApiBody({ type: OtherCarDamagePartDto })
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024,
|
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
},
|
},
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/car-green-cards",
|
destination: "./files/car-green-cards",
|
||||||
@@ -104,6 +110,7 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("/car-other-part-damage/:claimRequestID")
|
@Patch("/car-other-part-damage/:claimRequestID")
|
||||||
async carOtherPartDamage(
|
async carOtherPartDamage(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -119,35 +126,35 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("required-documents-status/:claimRequestID")
|
@Get("required-documents-status/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
async getRequiredDocumentsStatus(
|
async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
|
||||||
@Param("claimRequestID") requestId: string,
|
|
||||||
) {
|
|
||||||
return await this.claimRequestManagementService.getRequiredDocumentsStatus(
|
return await this.claimRequestManagementService.getRequiredDocumentsStatus(
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("car-part-image-required/:claimRequestID")
|
@Get("car-part-image-required/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||||
return await this.claimRequestManagementService.getImageRequiredList(
|
return await this.claimRequestManagementService.getImageRequiredList(
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
schema: {
|
// schema: {
|
||||||
type: "object",
|
// type: "object",
|
||||||
properties: {
|
// properties: {
|
||||||
file: { type: "string", format: "binary" },
|
// file: { type: "string", format: "binary" },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/claim-required-documents/",
|
destination: "./files/claim-required-documents/",
|
||||||
filename: (req, file, callback) => {
|
filename: (req, file, callback) => {
|
||||||
@@ -164,13 +171,14 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// // @ApiParam({ name: "claimRequestID" })
|
||||||
@ApiQuery({
|
// @ApiQuery({
|
||||||
name: "documentType",
|
// name: "documentType",
|
||||||
enum: ClaimRequiredDocumentType,
|
// enum: ClaimRequiredDocumentType,
|
||||||
description: "Type of required document to upload",
|
// description: "Type of required document to upload",
|
||||||
})
|
// })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("upload-required-document/:claimRequestID")
|
@Patch("upload-required-document/:claimRequestID")
|
||||||
async uploadRequiredDocument(
|
async uploadRequiredDocument(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -189,17 +197,17 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
schema: {
|
// schema: {
|
||||||
type: "object",
|
// type: "object",
|
||||||
properties: {
|
// properties: {
|
||||||
file: { type: "string", format: "binary" },
|
// file: { type: "string", format: "binary" },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/car-parts/",
|
destination: "./files/car-parts/",
|
||||||
filename: (req, file, callback) => {
|
filename: (req, file, callback) => {
|
||||||
@@ -217,12 +225,13 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
@ApiParam({
|
// @ApiParam({
|
||||||
name: "partId",
|
// name: "partId",
|
||||||
description: "The ID of the specific car part being photographed.",
|
// description: "The ID of the specific car part being photographed.",
|
||||||
})
|
// })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||||
async captureCarPartDamage(
|
async captureCarPartDamage(
|
||||||
@Param("partId") partId: string,
|
@Param("partId") partId: string,
|
||||||
@@ -239,17 +248,17 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
schema: {
|
// schema: {
|
||||||
type: "object",
|
// type: "object",
|
||||||
properties: {
|
// properties: {
|
||||||
file: { type: "string", format: "binary" },
|
// file: { type: "string", format: "binary" },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: { fileSize: 50 * 1024 * 1024 },
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/car-capture-videos/",
|
destination: "./files/car-capture-videos/",
|
||||||
filename: (req, file, callback) => {
|
filename: (req, file, callback) => {
|
||||||
@@ -261,8 +270,9 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
// @ApiParam({ name: "claimRequestID" })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("car-capture/:claimRequestID")
|
@Patch("car-capture/:claimRequestID")
|
||||||
async captureVideoCapture(
|
async captureVideoCapture(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -274,26 +284,26 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("requests/")
|
@Get("requests/")
|
||||||
async getRequest(@CurrentUser() currentUser) {
|
async getRequest(@CurrentUser() currentUser) {
|
||||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("request/:claimRequestId")
|
@Get("request/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
myRequests(
|
myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
|
||||||
@Param("claimRequestId") requestId: string,
|
|
||||||
@CurrentUser() user,
|
|
||||||
) {
|
|
||||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Put("request/reply/:claimRequestId")
|
@Put("request/reply/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024,
|
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
},
|
},
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/claim-sign",
|
destination: "./files/claim-sign",
|
||||||
@@ -306,33 +316,34 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
type: UserCommentDto,
|
// type: UserCommentDto,
|
||||||
description: "if partId null , you can upload video capture",
|
// description: "if partId null , you can upload video capture",
|
||||||
})
|
// })
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
async submitReply(
|
async submitReply(
|
||||||
@Param("claimRequestId") requestId,
|
@Param("claimRequestId") requestId,
|
||||||
@Body() body,
|
@Body() body,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user,
|
@CurrentUser() user,
|
||||||
) {
|
) {
|
||||||
return await this.claimRequestManagementService.submitUserReply(
|
// return await this.claimRequestManagementService.submitUserReply(
|
||||||
requestId,
|
// requestId,
|
||||||
body,
|
// body,
|
||||||
file,
|
// file,
|
||||||
user,
|
// user,
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Put("request/resend/:claimRequestId/objection")
|
@Put("request/resend/:claimRequestId/objection")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
@ApiConsumes("application/json")
|
// @ApiConsumes("application/json")
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
type: UserObjectionDto,
|
// type: UserObjectionDto,
|
||||||
description: "Objection details with optional new parts",
|
// description: "Objection details with optional new parts",
|
||||||
})
|
// })
|
||||||
async handleUserObjection(
|
async handleUserObjection(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() userObjectionDto: UserObjectionDto,
|
@Body() userObjectionDto: UserObjectionDto,
|
||||||
@@ -343,24 +354,25 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("request/resend/:claimRequestId")
|
@Patch("request/resend/:claimRequestId")
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
@ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
|
// @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
|
||||||
@ApiQuery({ name: "partId", required: false })
|
// @ApiQuery({ name: "partId", required: false })
|
||||||
@ApiQuery({ name: "documentName", required: false })
|
// @ApiQuery({ name: "documentName", required: false })
|
||||||
@ApiQuery({ name: "side", required: false })
|
// @ApiQuery({ name: "side", required: false })
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
schema: {
|
// schema: {
|
||||||
type: "object",
|
// type: "object",
|
||||||
properties: {
|
// properties: {
|
||||||
file: {
|
// file: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
format: "binary",
|
// format: "binary",
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
@@ -372,7 +384,7 @@ export class ClaimRequestManagementController {
|
|||||||
callback(null, filename);
|
callback(null, filename);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
async uploadDocuments(
|
async uploadDocuments(
|
||||||
@@ -393,9 +405,10 @@ export class ClaimRequestManagementController {
|
|||||||
* User satisfaction rating for a completed claim file.
|
* User satisfaction rating for a completed claim file.
|
||||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||||
*/
|
*/
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Put("request/:claimRequestId/user-rating")
|
@Put("request/:claimRequestId/user-rating")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: UserRatingDto })
|
// @ApiBody({ type: UserRatingDto })
|
||||||
async addUserRating(
|
async addUserRating(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() ratingDto: UserRatingDto,
|
@Body() ratingDto: UserRatingDto,
|
||||||
@@ -408,21 +421,22 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||||
@ApiConsumes("multipart/form-data")
|
// @ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
@ApiParam({ name: "partId" })
|
// @ApiParam({ name: "partId" })
|
||||||
@ApiBody({
|
// @ApiBody({
|
||||||
schema: {
|
// schema: {
|
||||||
type: "object",
|
// type: "object",
|
||||||
properties: {
|
// properties: {
|
||||||
file: {
|
// file: {
|
||||||
type: "string",
|
// type: "string",
|
||||||
format: "binary",
|
// format: "binary",
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
})
|
// })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
@@ -433,7 +447,7 @@ export class ClaimRequestManagementController {
|
|||||||
callback(null, filename);
|
callback(null, filename);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
async uploadFactorForPart(
|
async uploadFactorForPart(
|
||||||
@@ -450,8 +464,9 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({ type: InPersonVisitDto })
|
// @ApiBody({ type: InPersonVisitDto })
|
||||||
@ApiParam({ name: "id" })
|
// @ApiParam({ name: "id" })
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(
|
async inPersonVisit(
|
||||||
@Param("id") requestId: string,
|
@Param("id") requestId: string,
|
||||||
@@ -466,23 +481,25 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
|
// @ApiParam({ name: "insuranceId" })
|
||||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
|
||||||
insuranceId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Get("fanavaran-submit/:claimRequestId")
|
@Get("fanavaran-submit/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||||
return await this.claimRequestManagementService.fanavaranSubmit(
|
return await this.claimRequestManagementService.fanavaranSubmit(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ApiOperation({ deprecated: true })
|
||||||
@Post("fanavaran-submit/:claimRequestId")
|
@Post("fanavaran-submit/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
// @ApiParam({ name: "claimRequestId" })
|
||||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||||
return await this.claimRequestManagementService.submitToFanavaran(
|
return await this.claimRequestManagementService.submitToFanavaran(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
|||||||
import { UsersModule } from "src/users/users.module";
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||||
|
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||||
|
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
|
||||||
|
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||||
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
|
||||||
import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema";
|
import {
|
||||||
|
ClaimCase,
|
||||||
|
ClaimCaseSchema,
|
||||||
|
} from "./entites/schema/claim-cases.schema";
|
||||||
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
||||||
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
||||||
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
||||||
@@ -45,15 +51,21 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
|||||||
import { ClientModule } from "src/client/client.module";
|
import { ClientModule } from "src/client/client.module";
|
||||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
HttpModule,
|
||||||
|
FanavaranAuditModule,
|
||||||
PublicIdModule,
|
PublicIdModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
SandHubModule,
|
SandHubModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
MediaPolicyModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
@@ -85,7 +97,13 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
ClaimRequiredDocumentDbService,
|
ClaimRequiredDocumentDbService,
|
||||||
ClaimAccessGuard,
|
ClaimAccessGuard,
|
||||||
],
|
],
|
||||||
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
|
controllers: [
|
||||||
|
ClaimRequestManagementController,
|
||||||
|
ClaimRequestManagementV2Controller,
|
||||||
|
RegistrarClaimV1Controller,
|
||||||
|
ExpertInitiatedClaimMirrorController,
|
||||||
|
RegistrarClaimMirrorController,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ClaimRequestManagementService,
|
ClaimRequestManagementService,
|
||||||
ClaimRequestManagementDbService,
|
ClaimRequestManagementDbService,
|
||||||
@@ -93,6 +111,7 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
DamageImageDbService,
|
DamageImageDbService,
|
||||||
VideoCaptureDbService,
|
VideoCaptureDbService,
|
||||||
ClaimRequiredDocumentDbService,
|
ClaimRequiredDocumentDbService,
|
||||||
|
ClaimSignDbService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ClaimRequestManagementModule {}
|
export class ClaimRequestManagementModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,56 +2,84 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
HttpException,
|
HttpException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
|
BadRequestException,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Get,
|
Get,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
import { extname } from "path";
|
import { extname } from "node:path";
|
||||||
|
import { Types } from "mongoose";
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
|
import {
|
||||||
|
OuterPartCatalogItemDto,
|
||||||
|
SelectOuterPartsV2Dto,
|
||||||
|
SelectOuterPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-outer-parts-v2.dto";
|
||||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||||||
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
|
import {
|
||||||
|
CapturePartV2Dto,
|
||||||
|
CapturePartV2ResponseDto,
|
||||||
|
VideoCaptureV2ResponseDto,
|
||||||
|
} from "./dto/capture-part-v2.dto";
|
||||||
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||||
|
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||||
|
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
|
||||||
@ApiTags("claim-request-management (v2)")
|
@ApiTags("claim-request-management (v2)")
|
||||||
@Controller("v2/claim-request-management")
|
@Controller("v2/claim-request-management")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(GlobalGuard, RolesGuard)
|
@UseGuards(GlobalGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.USER)
|
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
||||||
export class ClaimRequestManagementV2Controller {
|
export class ClaimRequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("requests")
|
@Get("requests")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get My Claims (V2)",
|
summary: "Get My Claims (V2)",
|
||||||
description: "Get list of all claim requests for the current user.",
|
description:
|
||||||
|
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "List of user claims",
|
description: "List of user claims",
|
||||||
type: GetMyClaimsV2ResponseDto,
|
type: GetMyClaimsV2ResponseDto,
|
||||||
})
|
})
|
||||||
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
async getMyClaims(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
): Promise<GetMyClaimsV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
|
return await this.claimRequestManagementService.getMyClaimsV2(
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
query,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -68,7 +96,8 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get Claim Details (V2)",
|
summary: "Get Claim Details (V2)",
|
||||||
description: "Get full details of a claim request. Only the claim owner can access.",
|
description:
|
||||||
|
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -91,6 +120,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
return await this.claimRequestManagementService.getClaimDetailsV2(
|
return await this.claimRequestManagementService.getClaimDetailsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -100,6 +130,208 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos).
|
||||||
|
*/
|
||||||
|
@Post("request/:claimRequestId/expert-resend/acknowledge")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Acknowledge expert resend (instructions only)",
|
||||||
|
description:
|
||||||
|
"Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " +
|
||||||
|
"Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
||||||
|
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
|
||||||
|
async acknowledgeExpertResend(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2(
|
||||||
|
claimRequestId,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||||
|
*/
|
||||||
|
@Put("request/:claimRequestId/objection")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Submit user objection (V2)",
|
||||||
|
description:
|
||||||
|
"**Windows:** (1) **Insurer-review:** `ClaimCaseStatus` in **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, with `workflow.currentStep=INSURER_REVIEW` and no recorded **final** `evaluation.ownerInsurerApproval` — including **mixed** priced+factor gate (`NEEDS_REVISION` before priced-line signature for factors) or **final** totals (`claimStatus=APPROVED`). Not allowed while uploading factors (`OWNER_UPLOAD_FACTOR_DOCUMENTS`) or expert validation (`EXPERT_COST_EVALUATION`/`EXPERT_VALIDATING_REPAIR_FACTORS`).\n" +
|
||||||
|
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
|
||||||
|
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
|
||||||
|
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
|
||||||
|
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: UserObjectionV2Dto })
|
||||||
|
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||||
|
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
||||||
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||||
|
async submitUserObjectionV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UserObjectionV2Dto,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to submit objection",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
|
||||||
|
*/
|
||||||
|
@Put("request/:claimRequestId/user-rating")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Submit user satisfaction rating (V2)",
|
||||||
|
description:
|
||||||
|
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
|
||||||
|
"Stores scores on `ClaimCase.userRating` (0–5). One submission per case.",
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: UserRatingDto })
|
||||||
|
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||||
|
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
||||||
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||||
|
async addUserRatingV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() ratingDto: UserRatingDto,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.addUserRatingV2(
|
||||||
|
claimRequestId,
|
||||||
|
ratingDto,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to save rating",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
|
||||||
|
*/
|
||||||
|
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "Claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Sign priced lines or final claim pricing (owner)",
|
||||||
|
description:
|
||||||
|
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
|
||||||
|
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
|
||||||
|
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
|
||||||
|
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
description: "Signature file, agreement, and branch",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["sign", "agree", "branchId"],
|
||||||
|
properties: {
|
||||||
|
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||||
|
agree: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "true to accept expert pricing and complete the claim",
|
||||||
|
},
|
||||||
|
branchId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
||||||
|
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
||||||
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Already signed" })
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("sign", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-sign",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||||
|
callback(null, `${base}-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async submitOwnerInsurerApprovalSignV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body("agree") agree: string | boolean,
|
||||||
|
@Body("branchId") branchId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||||
|
throw new BadRequestException("Invalid claim request id");
|
||||||
|
}
|
||||||
|
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||||
|
const agreed =
|
||||||
|
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||||
|
claimRequestId,
|
||||||
|
agreed,
|
||||||
|
typeof branchId === "string" ? branchId : "",
|
||||||
|
sign,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to submit signature",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Post("create-from-blame/:blameRequestId")
|
@Post("create-from-blame/:blameRequestId")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "blameRequestId",
|
name: "blameRequestId",
|
||||||
@@ -127,6 +359,66 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
/**
|
/**
|
||||||
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
||||||
*/
|
*/
|
||||||
|
@Get("outer-parts-catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get outer parts catalog (V2)",
|
||||||
|
description:
|
||||||
|
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts catalog",
|
||||||
|
type: [OuterPartCatalogItemDto],
|
||||||
|
})
|
||||||
|
async getOuterPartsCatalog(
|
||||||
|
@Query("carType") carType?: ClaimVehicleTypeV2,
|
||||||
|
): Promise<OuterPartCatalogItemDto[]> {
|
||||||
|
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("branches/:insuranceId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get insurer branches (V2)",
|
||||||
|
description:
|
||||||
|
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "insuranceId",
|
||||||
|
description: "Insurer client id (MongoDB ObjectId)",
|
||||||
|
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "List of branches for insurer",
|
||||||
|
})
|
||||||
|
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
|
||||||
|
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
|
insuranceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("car-other-part")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get other (non-body) parts catalog",
|
||||||
|
description:
|
||||||
|
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Other parts catalog",
|
||||||
|
})
|
||||||
|
async getCarOtherPartsV2() {
|
||||||
|
const raw = await readFile(
|
||||||
|
`${process.cwd()}/src/static/car-part.json`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("select-outer-parts/:claimRequestId")
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||||
@@ -161,41 +453,35 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: SelectOuterPartsV2Dto,
|
type: SelectOuterPartsV2Dto,
|
||||||
description: "Array of selected damaged outer parts",
|
description:
|
||||||
|
"Selected vehicle type + selected outer part IDs from catalog",
|
||||||
examples: {
|
examples: {
|
||||||
example1: {
|
example1: {
|
||||||
summary: "Minor front damage",
|
summary: "Sedan - minor front damage",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: ["hood", "front_bumper", "front_right_fender"],
|
carType: "sedan",
|
||||||
|
selectedPartIds: [19, 21, 16],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example2: {
|
example2: {
|
||||||
summary: "Side impact damage",
|
summary: "SUV - left side impact",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: [
|
carType: "suv",
|
||||||
"front_left_door",
|
selectedPartIds: [102, 103, 104, 107],
|
||||||
"rear_left_door",
|
|
||||||
"front_left_fender",
|
|
||||||
"rear_left_fender",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example3: {
|
example3: {
|
||||||
summary: "Rear-end collision",
|
summary: "Hatchback - rear-end collision",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
|
carType: "hatchback",
|
||||||
|
selectedPartIds: [225, 226, 210],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example4: {
|
example4: {
|
||||||
summary: "Multiple damage areas",
|
summary: "Pickup - two sides + roof",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: [
|
carType: "pickup",
|
||||||
"hood",
|
selectedPartIds: [319, 312, 330],
|
||||||
"front_bumper",
|
|
||||||
"front_right_door",
|
|
||||||
"rear_bumper",
|
|
||||||
"trunk",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -231,6 +517,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -252,13 +539,14 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
**Validations:**
|
**Validations:**
|
||||||
- Claim must exist
|
- Claim must exist
|
||||||
- User must be the claim owner (damaged party from blame case)
|
- User must be the claim owner (damaged party from blame case)
|
||||||
- Current workflow step must be SELECT_OTHER_PARTS
|
- Current workflow step must be SELECT_OTHER_PARTS
|
||||||
- Bank information must not have been submitted previously
|
- Bank information must not have been submitted previously
|
||||||
- Sheba number must be exactly 24 digits
|
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||||
- National code must be exactly 10 digits
|
- National code must be exactly 10 digits
|
||||||
|
|
||||||
**Valid Other Parts (Optional):**
|
**Valid Other Parts (Optional):**
|
||||||
@@ -267,8 +555,8 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
- headlight, taillight, mirror, glass
|
- headlight, taillight, mirror, glass
|
||||||
|
|
||||||
**After Success:**
|
**After Success:**
|
||||||
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4)
|
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||||
- User can proceed to upload required documents
|
- User captures car angles and damaged-part photos, then uploads required documents (Step 5)
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -276,42 +564,38 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
description: "The claim case ID (MongoDB ObjectId)",
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
example: "507f1f77bcf86cd799439011",
|
example: "507f1f77bcf86cd799439011",
|
||||||
})
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-required-document",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `other-parts-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: SelectOtherPartsV2Dto,
|
description:
|
||||||
description: "Other parts selection and bank information",
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
|
||||||
examples: {
|
schema: {
|
||||||
example1: {
|
type: "object",
|
||||||
summary: "Only bank info (no other parts damaged)",
|
properties: {
|
||||||
value: {
|
otherParts: {
|
||||||
otherParts: [],
|
oneOf: [
|
||||||
shebaNumber: "123456789012345678901234",
|
{ type: "array", items: { type: "string" } },
|
||||||
nationalCodeOfOwner: "1234567890",
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
},
|
],
|
||||||
},
|
example: ["engine", "suspension"],
|
||||||
example2: {
|
|
||||||
summary: "Engine and suspension damage",
|
|
||||||
value: {
|
|
||||||
otherParts: ["engine", "suspension"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
example3: {
|
|
||||||
summary: "Multiple systems damaged",
|
|
||||||
value: {
|
|
||||||
otherParts: ["engine", "brake_system", "electrical", "headlight"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
example4: {
|
|
||||||
summary: "Lighting and glass damage",
|
|
||||||
value: {
|
|
||||||
otherParts: ["headlight", "taillight", "mirror", "glass"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
},
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -339,12 +623,17 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() body: SelectOtherPartsV2Dto,
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
|
// Green-card photo is optional here — the helper no-ops on missing file.
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.selectOtherPartsV2(
|
return await this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
|
file,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -364,11 +653,13 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
summary: "Get Capture Requirements (V2)",
|
summary: "Get Capture Requirements (V2)",
|
||||||
description: `
|
description: `
|
||||||
**Get list of what needs to be captured:**
|
**Get list of what needs to be captured:**
|
||||||
- Required documents (13 items)
|
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
|
||||||
- Car angles (4 items: front, back, left, right)
|
- Car angles (4 items: front, back, left, right)
|
||||||
- Damaged parts (based on selected outer parts)
|
- Damaged parts (based on selected outer parts)
|
||||||
|
|
||||||
Returns status of each item (uploaded/captured or not).
|
Returns status of each item (uploaded/captured or not).
|
||||||
|
|
||||||
|
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -397,6 +688,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -415,7 +707,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB
|
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
},
|
},
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/claim-documents",
|
destination: "./files/claim-documents",
|
||||||
@@ -430,20 +722,19 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Upload Required Document (V2 - Step 4)",
|
summary: "Upload Required Document (V2 - Step 5)",
|
||||||
description: `
|
description: `
|
||||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
**Upload one of the 13 required documents:**
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
- car_green_card
|
|
||||||
- damaged_driving_license_front/back
|
- damaged_driving_license_front/back
|
||||||
- damaged_chassis_number, damaged_engine_photo
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
- damaged_car_card_front/back, damaged_metal_plate
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
- guilty_driving_license_front/back
|
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||||
- guilty_car_card_front/back, guilty_metal_plate
|
|
||||||
|
|
||||||
**When all 13 documents are uploaded:**
|
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
|
||||||
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
|
|
||||||
|
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -459,7 +750,6 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
documentKey: {
|
documentKey: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: [
|
enum: [
|
||||||
"car_green_card",
|
|
||||||
"damaged_driving_license_front",
|
"damaged_driving_license_front",
|
||||||
"damaged_driving_license_back",
|
"damaged_driving_license_back",
|
||||||
"damaged_chassis_number",
|
"damaged_chassis_number",
|
||||||
@@ -473,7 +763,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
"guilty_car_card_back",
|
"guilty_car_card_back",
|
||||||
"guilty_metal_plate",
|
"guilty_metal_plate",
|
||||||
],
|
],
|
||||||
example: "car_green_card",
|
example: "damaged_driving_license_front",
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -501,12 +791,14 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
|
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
file,
|
file,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -523,7 +815,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", {
|
FileInterceptor("file", {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB
|
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
},
|
},
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: "./files/claim-captures",
|
destination: "./files/claim-captures",
|
||||||
@@ -538,15 +830,17 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)",
|
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||||
description: `
|
description: `
|
||||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim)
|
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||||
|
|
||||||
**Capture types:**
|
**Capture types:**
|
||||||
1. **angle**: Car angles (front, back, left, right) - 4 required
|
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||||
|
|
||||||
**All captures must be completed before user can submit claim.**
|
**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
|
||||||
|
|
||||||
|
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
@@ -592,12 +886,14 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
): Promise<CapturePartV2ResponseDto> {
|
): Promise<CapturePartV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.capturePartV2(
|
return await this.claimRequestManagementService.capturePartV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
file,
|
file,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -606,4 +902,132 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
|
||||||
|
*/
|
||||||
|
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload repair factor file for a factor-needed part (V2)",
|
||||||
|
description:
|
||||||
|
"Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" +
|
||||||
|
"**Requires:** `ClaimCaseStatus` **`OWNER_REPAIR_FACTOR_UPLOAD_PENDING`** or **`INSURER_REVIEW_MIXED_FACTORS_PENDING`** (or legacy **`WAITING_FOR_INSURER_APPROVAL`**), `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" +
|
||||||
|
"**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" +
|
||||||
|
"When every `factorNeeded` line has `factorLink`, the case moves to **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||||
|
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-factors",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
callback(null, `-${unique}-${file.originalname}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async uploadFactorForPartV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Param("partId") partId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||||
|
claimRequestId,
|
||||||
|
partId,
|
||||||
|
file,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to upload factor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
|
||||||
|
*/
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/car-capture-videos/",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `claim-video-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@Patch("car-capture/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload car walk-around video (V2)",
|
||||||
|
description:
|
||||||
|
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
|
||||||
|
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
|
||||||
|
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Video uploaded successfully",
|
||||||
|
type: VideoCaptureV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||||
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||||
|
async captureVideoCaptureV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
): Promise<VideoCaptureV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
|
claimRequestId,
|
||||||
|
file,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 DTO for capturing car angle or damaged part
|
* V2 DTO for capturing car angle or damaged part
|
||||||
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
|
|||||||
captureType: 'angle' | 'part';
|
captureType: 'angle' | 'part';
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Key of the angle or part being captured',
|
description:
|
||||||
|
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
|
||||||
example: 'front',
|
example: 'front',
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Capture key is required' })
|
@IsNotEmpty({ message: 'Capture key is required' })
|
||||||
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
|
|||||||
example: 'Angle captured successfully. 6 captures remaining.',
|
example: 'Angle captured successfully. 6 captures remaining.',
|
||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
|
||||||
|
})
|
||||||
|
expertResendComplete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for car walk-around video upload (V2)
|
||||||
|
*/
|
||||||
|
export class VideoCaptureV2ResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Claim case ID',
|
||||||
|
example: '507f1f77bcf86cd799439011',
|
||||||
|
})
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'ID of the stored video document (claim-video-capture)',
|
||||||
|
example: '507f1f77bcf86cd799439012',
|
||||||
|
})
|
||||||
|
videoId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Success message',
|
||||||
|
example: 'Video capture uploaded successfully.',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsInt, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for required document item
|
* DTO for required document item
|
||||||
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
|
|||||||
enum: ['general', 'damaged_party', 'guilty_party'],
|
enum: ['general', 'damaged_party', 'guilty_party'],
|
||||||
})
|
})
|
||||||
category: string;
|
category: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
preferUploadDuringCapture?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +79,17 @@ export class CarAngleItem {
|
|||||||
*/
|
*/
|
||||||
export class DamagedPartItem {
|
export class DamagedPartItem {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Part key',
|
description:
|
||||||
example: 'hood',
|
'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
|
||||||
|
example: 'backfender',
|
||||||
})
|
})
|
||||||
key: string;
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Vehicle side / region (left, right, front, back, top)',
|
||||||
|
example: 'left',
|
||||||
|
})
|
||||||
|
side?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Display label in Farsi',
|
description: 'Display label in Farsi',
|
||||||
@@ -82,6 +97,12 @@ export class DamagedPartItem {
|
|||||||
})
|
})
|
||||||
label_fa: string;
|
label_fa: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Deprecated: same as `name` (kept for older clients)',
|
||||||
|
example: 'backfender',
|
||||||
|
})
|
||||||
|
key?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Display label in English',
|
description: 'Display label in English',
|
||||||
example: 'Hood',
|
example: 'Hood',
|
||||||
@@ -93,6 +114,12 @@ export class DamagedPartItem {
|
|||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
captured: boolean;
|
captured: boolean;
|
||||||
|
|
||||||
|
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
|
||||||
|
@ApiPropertyOptional({ example: 12 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +144,20 @@ export class GetCaptureRequirementsV2ResponseDto {
|
|||||||
})
|
})
|
||||||
currentStep: string;
|
currentStep: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Ordered capture phase during CAPTURE_PART_DAMAGES: parts → angles → capture_phase_documents',
|
||||||
|
example: 'angles',
|
||||||
|
enum: ['parts', 'angles', 'capture_phase_documents', 'complete'],
|
||||||
|
})
|
||||||
|
captureSequencePhase: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Human-readable hint for what the user should do next in the capture step',
|
||||||
|
example: 'Capture all four car angles (front, back, left, right) next.',
|
||||||
|
})
|
||||||
|
captureSequenceHint: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'List of required documents to upload',
|
description: 'List of required documents to upload',
|
||||||
type: [RequiredDocumentItem],
|
type: [RequiredDocumentItem],
|
||||||
@@ -159,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
|
|||||||
anglesTotal: number;
|
anglesTotal: number;
|
||||||
partsCaptured: number;
|
partsCaptured: number;
|
||||||
partsTotal: number;
|
partsTotal: number;
|
||||||
|
capturePhaseDocsRemaining: number;
|
||||||
|
postCaptureDocumentsRemaining: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,94 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
|
||||||
|
|
||||||
|
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
|
||||||
|
export class ClaimDetailsOwnerNextActionV2Dto {
|
||||||
|
@ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' })
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' })
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Path under API root',
|
||||||
|
example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign',
|
||||||
|
})
|
||||||
|
pathTemplate?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'What this endpoint does for the user' })
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */
|
||||||
|
export class ClaimDetailsOwnerGuidanceV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Machine-readable phase',
|
||||||
|
enum: [
|
||||||
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED',
|
||||||
|
'EXPERT_RESEND',
|
||||||
|
'WAITING_DAMAGE_EXPERT',
|
||||||
|
'EXPERT_REVIEWING',
|
||||||
|
'USER_FLOW',
|
||||||
|
'UPLOAD_FACTORS',
|
||||||
|
'EXPERT_VALIDATING_FACTORS',
|
||||||
|
'SIGN_PRICED_LINES',
|
||||||
|
'INSURER_REVIEW_NEEDS_REVISION',
|
||||||
|
'FINAL_SIGN_OR_REJECT',
|
||||||
|
'INSURER_APPROVAL_FALLBACK',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
phaseKey: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Short headline for the current phase' })
|
||||||
|
headline: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Longer UX copy' })
|
||||||
|
detail?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] })
|
||||||
|
nextActions: ClaimDetailsOwnerNextActionV2Dto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Whether PUT objection is permitted (see server validation for exact rules)',
|
||||||
|
})
|
||||||
|
objectionAllowed?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' })
|
||||||
|
objectionHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active damage-expert resend request (owner must upload or acknowledge). */
|
||||||
|
export class ExpertResendDetailsV2Dto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
resendDescription?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
resendDocuments?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Damaged parts the expert asked to re-capture (same shape as damagedParts: id, name, side, label_fa, catalogKey, captured, url).",
|
||||||
|
type: [Object],
|
||||||
|
})
|
||||||
|
resendCarParts?: Array<{
|
||||||
|
id?: number | null;
|
||||||
|
name?: string;
|
||||||
|
side?: string;
|
||||||
|
label_fa?: string;
|
||||||
|
label_en?: string;
|
||||||
|
catalogKey?: string;
|
||||||
|
key?: string;
|
||||||
|
captured?: boolean;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
|
||||||
|
fulfilledAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export class ClaimDetailsV2ResponseDto {
|
export class ClaimDetailsV2ResponseDto {
|
||||||
@ApiProperty({ description: 'Claim case ID' })
|
@ApiProperty({ description: 'Claim case ID' })
|
||||||
@@ -10,7 +100,11 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
@ApiProperty({ description: 'Request number' })
|
@ApiProperty({ description: 'Request number' })
|
||||||
requestNo: string;
|
requestNo: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Overall case status' })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
|
||||||
|
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
||||||
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Claim damage status' })
|
@ApiProperty({ description: 'Claim damage status' })
|
||||||
@@ -28,9 +122,13 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Blame request number' })
|
@ApiPropertyOptional({ description: 'Blame request number' })
|
||||||
blameRequestNo?: string;
|
blameRequestNo?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Owner info' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
|
||||||
|
})
|
||||||
owner?: {
|
owner?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
clientId?: string;
|
||||||
|
userClientKey?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,8 +140,11 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
plate?: any;
|
plate?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
@ApiPropertyOptional({
|
||||||
selectedParts?: string[];
|
description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
|
||||||
|
type: [DamageSelectedPartV2BodyDto],
|
||||||
|
})
|
||||||
|
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||||
otherParts?: string[];
|
otherParts?: string[];
|
||||||
@@ -54,14 +155,73 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
nationalCodeOfOwner?: string;
|
nationalCodeOfOwner?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
@ApiPropertyOptional({ description: 'Required documents status (link instead of id)' })
|
||||||
requiredDocuments?: Record<string, { uploaded: boolean; fileId?: string }>;
|
requiredDocuments?: Record<string, { uploaded: boolean; fileUrl?: string }>;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
description:
|
||||||
|
'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
index: { type: 'number' },
|
||||||
|
id: { type: 'number', nullable: true },
|
||||||
|
name: { type: 'string' },
|
||||||
|
side: { type: 'string' },
|
||||||
|
label_fa: { type: 'string' },
|
||||||
|
captured: { type: 'boolean' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
path: { type: 'string' },
|
||||||
|
fileName: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedParts?: Array<{
|
||||||
|
index: number;
|
||||||
|
id?: number | null;
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
label_fa: string;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||||
|
})
|
||||||
|
expertResend?: ExpertResendDetailsV2Dto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Damage expert opinion(s): initial and final (after objection).",
|
||||||
|
type: Object,
|
||||||
|
})
|
||||||
|
evaluation?: {
|
||||||
|
damageExpertReply?: unknown;
|
||||||
|
damageExpertReplyFinal?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: ClaimDetailsOwnerGuidanceV2Dto,
|
||||||
|
description:
|
||||||
|
'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.',
|
||||||
|
})
|
||||||
|
ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||||
|
userRating?: {
|
||||||
|
progressSpeed: number;
|
||||||
|
registrationEase: number;
|
||||||
|
overallEvaluation: number;
|
||||||
|
comment?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiProperty({ description: 'Created at' })
|
@ApiProperty({ description: 'Created at' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsInt, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
/** Stored shape / API payload for one selected outer damaged part (V2). */
|
||||||
|
export class DamageSelectedPartV2BodyDto {
|
||||||
|
@ApiPropertyOptional({ description: "Catalog id when from outer-parts catalog" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
id?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "backfender" })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "left" })
|
||||||
|
@IsString()
|
||||||
|
side: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "گلگیر عقب" })
|
||||||
|
@IsString()
|
||||||
|
label_fa: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Original catalog key, e.g. left_backfender" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogKey?: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class ClaimListItemV2Dto {
|
export class ClaimListItemV2Dto {
|
||||||
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
|
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
|
||||||
@@ -10,7 +10,11 @@ export class ClaimListItemV2Dto {
|
|||||||
@ApiProperty({ description: 'Claim request number', example: 'CL12345' })
|
@ApiProperty({ description: 'Claim request number', example: 'CL12345' })
|
||||||
requestNo: string;
|
requestNo: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
|
||||||
|
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
|
||||||
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
|
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
|
||||||
@@ -30,6 +34,17 @@ export class GetMyClaimsV2ResponseDto {
|
|||||||
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
|
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
|
||||||
list: ClaimListItemV2Dto[];
|
list: ClaimListItemV2Dto[];
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total count', example: 5 })
|
@ApiProperty({ description: 'Total count after search filter', example: 5 })
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Current page when `page` or `limit` query params were sent',
|
||||||
|
})
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Page size when paginating' })
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total pages when paginating' })
|
||||||
|
totalPages?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
|
|||||||
otherParts?: OtherCarPart[];
|
otherParts?: OtherCarPart[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
|
description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
|
||||||
|
example: 'IR123456789012345678901234',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ message: 'sheba is required' })
|
||||||
|
@IsString({ message: 'sheba must be a string' })
|
||||||
|
sheba: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Legacy alias of sheba for backward compatibility',
|
||||||
example: '123456789012345678901234',
|
example: '123456789012345678901234',
|
||||||
pattern: '^[0-9]{24}$',
|
|
||||||
minLength: 24,
|
|
||||||
maxLength: 24,
|
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Sheba number is required' })
|
@IsOptional()
|
||||||
@IsString({ message: 'Sheba number must be a string' })
|
@IsString()
|
||||||
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
|
shebaNumber?: string;
|
||||||
@Matches(/^[0-9]{24}$/, {
|
|
||||||
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
|
|
||||||
})
|
|
||||||
shebaNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'National code of the owner - 10 digits',
|
description: 'National code of insurer/owner - 10 digits',
|
||||||
example: '1234567890',
|
example: '1234567890',
|
||||||
pattern: '^[0-9]{10}$',
|
pattern: '^[0-9]{10}$',
|
||||||
minLength: 10,
|
minLength: 10,
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'National code of owner is required' })
|
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
||||||
@IsString({ message: 'National code must be a string' })
|
@IsString({ message: 'National code must be a string' })
|
||||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||||
@Matches(/^[0-9]{10}$/, {
|
@Matches(/^[0-9]{10}$/, {
|
||||||
message: 'National code must contain exactly 10 digits',
|
message: 'National code must contain exactly 10 digits',
|
||||||
})
|
})
|
||||||
nationalCodeOfOwner: string;
|
nationalCodeOfInsurer: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Legacy alias for backward compatibility',
|
||||||
|
example: '1234567890',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalCodeOfOwner?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,19 +117,20 @@ export class SelectOtherPartsV2ResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Current workflow step',
|
description: 'Current workflow step',
|
||||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
example: 'CAPTURE_PART_DAMAGES',
|
||||||
})
|
})
|
||||||
currentStep: string;
|
currentStep: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Next possible workflow step',
|
description: 'Next possible workflow step',
|
||||||
example: 'CAPTURE_PART_DAMAGES',
|
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||||
})
|
})
|
||||||
nextStep: string;
|
nextStep: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Success message',
|
description: 'Success message',
|
||||||
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
|
example:
|
||||||
|
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
|
||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
} from "class-validator";
|
||||||
|
import { OtherCarPart } from "./select-other-parts-v2.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3 in-person expert flow: other parts only (sheba/national code collected during run-inquiries).
|
||||||
|
*/
|
||||||
|
export class SelectOtherPartsV3Dto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Array of selected other damaged parts (non-body parts)",
|
||||||
|
example: ["engine", "suspension", "headlight"],
|
||||||
|
enum: OtherCarPart,
|
||||||
|
isArray: true,
|
||||||
|
maxItems: 11,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "otherParts must be an array" })
|
||||||
|
@ArrayMaxSize(11, { message: "Maximum 11 other parts can be selected" })
|
||||||
|
@IsEnum(OtherCarPart, {
|
||||||
|
each: true,
|
||||||
|
message: "Invalid part name. Must be one of the valid other car parts",
|
||||||
|
})
|
||||||
|
otherParts?: OtherCarPart[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
|
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
|
||||||
|
import {
|
||||||
|
ClaimVehicleTypeV2,
|
||||||
|
OuterPartSideV2,
|
||||||
|
} from "src/static/outer-car-parts-catalog";
|
||||||
|
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum for valid outer car parts that can be damaged
|
* Enum for valid outer car parts that can be damaged
|
||||||
@@ -43,7 +48,7 @@ export class SelectOuterPartsV2Dto {
|
|||||||
minItems: 1,
|
minItems: 1,
|
||||||
maxItems: 13,
|
maxItems: 13,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
|
@IsOptional()
|
||||||
@IsArray({ message: 'selectedParts must be an array' })
|
@IsArray({ message: 'selectedParts must be an array' })
|
||||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||||
@@ -51,7 +56,29 @@ export class SelectOuterPartsV2Dto {
|
|||||||
each: true,
|
each: true,
|
||||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
message: 'Invalid part name. Must be one of the valid outer car parts',
|
||||||
})
|
})
|
||||||
selectedParts: OuterCarPart[];
|
selectedParts?: OuterCarPart[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Selected outer part IDs from catalog',
|
||||||
|
example: [9, 10, 4],
|
||||||
|
type: [Number],
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'selectedPartIds must be an array' })
|
||||||
|
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
|
||||||
|
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
|
||||||
|
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
|
||||||
|
selectedPartIds?: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Vehicle type for validating available outer parts',
|
||||||
|
enum: ClaimVehicleTypeV2,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(ClaimVehicleTypeV2)
|
||||||
|
carType?: ClaimVehicleTypeV2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +98,18 @@ export class SelectOuterPartsV2ResponseDto {
|
|||||||
publicId: string;
|
publicId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Array of selected damaged parts',
|
description:
|
||||||
example: ['hood', 'front_right_door', 'rear_bumper'],
|
"Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
|
||||||
|
type: [DamageSelectedPartV2BodyDto],
|
||||||
})
|
})
|
||||||
selectedParts: string[];
|
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Selected part IDs',
|
||||||
|
example: [9, 7, 11],
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
selectedPartIds: number[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Current workflow step',
|
description: 'Current workflow step',
|
||||||
@@ -94,3 +129,65 @@ export class SelectOuterPartsV2ResponseDto {
|
|||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetClaimVehicleTypeV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Vehicle type selected by user",
|
||||||
|
enum: ClaimVehicleTypeV2,
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(ClaimVehicleTypeV2)
|
||||||
|
carType: ClaimVehicleTypeV2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
|
||||||
|
* controllers). It mirrors how items are persisted under
|
||||||
|
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
|
||||||
|
* can match catalog rows to stored selections without any field renaming:
|
||||||
|
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
|
||||||
|
* parentheses, and the original full catalog key (`left_backfender`) is
|
||||||
|
* exposed as `catalogKey`.
|
||||||
|
*/
|
||||||
|
export class OuterPartCatalogItemDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Static catalog id (unique across all car types)",
|
||||||
|
example: 102,
|
||||||
|
})
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Side-agnostic part name (matches stored part `name`)",
|
||||||
|
example: "backWheel",
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Vehicle side / region",
|
||||||
|
enum: OuterPartSideV2,
|
||||||
|
example: "left",
|
||||||
|
})
|
||||||
|
side: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Display label in Farsi, with side disambiguator in parentheses when needed",
|
||||||
|
example: "چرخ عقب (چپ)",
|
||||||
|
})
|
||||||
|
label_fa: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Original full catalog key (matches stored `catalogKey`)",
|
||||||
|
example: "left_backWheel",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
catalogKey?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Vehicle type this catalog row belongs to",
|
||||||
|
enum: ClaimVehicleTypeV2,
|
||||||
|
required: false,
|
||||||
|
example: "suv",
|
||||||
|
})
|
||||||
|
carType?: ClaimVehicleTypeV2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||||
|
|
||||||
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
|
|||||||
example: 'Document uploaded successfully. 12 documents remaining.',
|
example: 'Document uploaded successfully. 12 documents remaining.',
|
||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
|
||||||
|
})
|
||||||
|
expertResendComplete?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
32
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
Validate,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||||
|
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
|
||||||
|
* with nested validation enabled for the v2 controller pipeline.
|
||||||
|
*/
|
||||||
|
export class UserObjectionV2Dto {
|
||||||
|
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||||
|
@Validate(HasObjectionEntriesConstraint)
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UserObjectionPartDto)
|
||||||
|
objectionParts?: UserObjectionPartDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||||
|
@Validate(HasObjectionEntriesConstraint)
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => NewPartDto)
|
||||||
|
newParts?: NewPartDto[];
|
||||||
|
}
|
||||||
@@ -1,47 +1,113 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
Validate,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||||
|
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
|
||||||
|
import { ObjectionPartHasContentConstraint } from "src/common/validators/objection-part-content.validator";
|
||||||
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||||
|
|
||||||
export class UserObjectionPartDto {
|
export class UserObjectionPartDto {
|
||||||
@ApiProperty()
|
@ApiProperty({
|
||||||
partId: string;
|
example: 201,
|
||||||
|
description: "Numeric catalog part id of the priced line being disputed.",
|
||||||
|
})
|
||||||
|
@Validate(ObjectionPartHasContentConstraint)
|
||||||
|
@IsInt()
|
||||||
|
partId: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Why the owner disagrees (min 3 chars when provided). Required unless partPrice or partSalary is sent.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional({
|
||||||
|
description: "Owner-proposed part price (Toman, integer string).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
@IsRepairLineAmountToman()
|
||||||
partPrice?: string;
|
partPrice?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional({
|
||||||
|
description: "Owner-proposed salary / labor (Toman, integer string).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
@IsRepairLineAmountToman()
|
||||||
partSalary?: string;
|
partSalary?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional({ enum: TypeOfDamage })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TypeOfDamage)
|
||||||
typeOfDamage?: TypeOfDamage;
|
typeOfDamage?: TypeOfDamage;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
carPartDamage?: string;
|
carPartDamage?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
side?: string;
|
side?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NewPartDto {
|
export class NewPartDto {
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiPropertyOptional({
|
||||||
partId: string | null;
|
nullable: true,
|
||||||
|
description: "Optional catalog id when the new part exists in the outer catalog.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
partId?: number | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ example: "سپر جلو" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
partName: string;
|
partName: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiPropertyOptional({ example: "front" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
side?: string;
|
side?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserObjectionDto {
|
export class UserObjectionDto {
|
||||||
@ApiProperty({ type: [UserObjectionPartDto], required: false })
|
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||||
|
@Validate(HasObjectionEntriesConstraint)
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
@Type(() => UserObjectionPartDto)
|
@Type(() => UserObjectionPartDto)
|
||||||
objectionParts?: UserObjectionPartDto[];
|
objectionParts?: UserObjectionPartDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [NewPartDto], required: false })
|
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||||
|
@Validate(HasObjectionEntriesConstraint)
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
@Type(() => NewPartDto)
|
@Type(() => NewPartDto)
|
||||||
newParts?: NewPartDto[];
|
newParts?: NewPartDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
|
|||||||
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOneAndUpdate(
|
||||||
|
filter: FilterQuery<ClaimCase>,
|
||||||
|
update: any,
|
||||||
|
options?: { new?: boolean },
|
||||||
|
): Promise<ClaimCaseDocument | null> {
|
||||||
|
return this.claimCaseModel.findOneAndUpdate(filter, update, {
|
||||||
|
new: options?.new !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async find(
|
async find(
|
||||||
filter: FilterQuery<ClaimCase>,
|
filter: FilterQuery<ClaimCase>,
|
||||||
options?: { lean?: boolean; select?: string },
|
options?: { lean?: boolean; select?: string },
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { Schema as MongooseSchema } from "mongoose";
|
||||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimDamageSelection {
|
export class ClaimDamageSelection {
|
||||||
/**
|
/**
|
||||||
* V2: Array of selected damaged outer car part names
|
* V2: Selected outer damaged parts (ordered). Prefer objects
|
||||||
* Examples: ['hood', 'front_right_door', 'rear_bumper']
|
* `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated.
|
||||||
*/
|
*/
|
||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
selectedParts?: string[];
|
selectedParts?: unknown[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Array of selected other (non-body) damaged parts
|
* V2: Array of selected other (non-body) damaged parts
|
||||||
@@ -28,4 +29,3 @@ export class ClaimDamageSelection {
|
|||||||
}
|
}
|
||||||
export const ClaimDamageSelectionSchema =
|
export const ClaimDamageSelectionSchema =
|
||||||
SchemaFactory.createForClass(ClaimDamageSelection);
|
SchemaFactory.createForClass(ClaimDamageSelection);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { Schema as MongooseSchema, Types } from "mongoose";
|
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
ExpertProfileSnapshot,
|
||||||
|
ExpertProfileSnapshotSchema,
|
||||||
|
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||||
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
|
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimPartPricing {
|
export class ClaimPartPricing {
|
||||||
@Prop({ type: String })
|
@Prop({ type: Number })
|
||||||
partId: string;
|
partId: number;
|
||||||
|
|
||||||
@Prop({ type: String })
|
/**
|
||||||
carPartDamage?: string;
|
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
||||||
|
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
|
||||||
|
* Must be Mixed — objects cannot cast to String.
|
||||||
|
*/
|
||||||
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
|
carPartDamage?: unknown;
|
||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
typeOfDamage?: string;
|
typeOfDamage?: string;
|
||||||
@@ -23,8 +32,9 @@ export class ClaimPartPricing {
|
|||||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
totalPayment?: string | number;
|
totalPayment?: string | number;
|
||||||
|
|
||||||
|
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
|
||||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
daghi?: string | number;
|
daghi?: string | number | Record<string, unknown>;
|
||||||
|
|
||||||
@Prop({ type: Boolean, default: false })
|
@Prop({ type: Boolean, default: false })
|
||||||
factorNeeded?: boolean;
|
factorNeeded?: boolean;
|
||||||
@@ -67,6 +77,43 @@ export class ClaimUserComment {
|
|||||||
export const ClaimUserCommentSchema =
|
export const ClaimUserCommentSchema =
|
||||||
SchemaFactory.createForClass(ClaimUserComment);
|
SchemaFactory.createForClass(ClaimUserComment);
|
||||||
|
|
||||||
|
/** Owner acceptance + signature after expert pricing (post-expert insurer `INSURER_REVIEW` — `INSURER_REVIEW_AWAITING_OWNER_SIGN` / `INSURER_REVIEW_MIXED_FACTORS_PENDING` / legacy `WAITING_FOR_INSURER_APPROVAL`). */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimOwnerInsurerApproval {
|
||||||
|
@Prop({ type: Boolean, required: true })
|
||||||
|
agree: boolean;
|
||||||
|
|
||||||
|
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
signDetailId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: () => new Date() })
|
||||||
|
signedAt?: Date;
|
||||||
|
}
|
||||||
|
export const ClaimOwnerInsurerApprovalSchema =
|
||||||
|
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
||||||
|
|
||||||
|
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimOwnerPricedPartsApproval {
|
||||||
|
@Prop({ type: Boolean, required: true })
|
||||||
|
agree: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
signDetailId?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: () => new Date() })
|
||||||
|
signedAt?: Date;
|
||||||
|
}
|
||||||
|
export const ClaimOwnerPricedPartsApprovalSchema =
|
||||||
|
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimExpertReply {
|
export class ClaimExpertReply {
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
@@ -83,14 +130,19 @@ export class ClaimExpertReply {
|
|||||||
|
|
||||||
@Prop({ type: ClaimUserCommentSchema })
|
@Prop({ type: ClaimUserCommentSchema })
|
||||||
userComment?: ClaimUserComment;
|
userComment?: ClaimUserComment;
|
||||||
|
|
||||||
|
/** Damage expert profile at reply time (from `damage-expert` collection). */
|
||||||
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
|
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
}
|
}
|
||||||
export const ClaimExpertReplySchema =
|
export const ClaimExpertReplySchema =
|
||||||
SchemaFactory.createForClass(ClaimExpertReply);
|
SchemaFactory.createForClass(ClaimExpertReply);
|
||||||
|
|
||||||
|
/** One line item the user disputes on an expert-priced part */
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimUserObjection {
|
export class ClaimUserObjectionPart {
|
||||||
@Prop({ type: String, required: true })
|
@Prop({ type: Number, required: true })
|
||||||
partId: string;
|
partId: number;
|
||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -103,9 +155,48 @@ export class ClaimUserObjection {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
typeOfDamage?: string;
|
typeOfDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
carPartDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
side?: string;
|
||||||
}
|
}
|
||||||
export const ClaimUserObjectionSchema =
|
export const ClaimUserObjectionPartSchema =
|
||||||
SchemaFactory.createForClass(ClaimUserObjection);
|
SchemaFactory.createForClass(ClaimUserObjectionPart);
|
||||||
|
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimUserObjectionNewPart {
|
||||||
|
/** Catalog id when known; otherwise a generated string id for the new line. */
|
||||||
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
|
partId?: number | string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
partName: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
side?: string;
|
||||||
|
}
|
||||||
|
export const ClaimUserObjectionNewPartSchema =
|
||||||
|
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||||
|
* Stored on {@link ClaimEvaluation.objection}.
|
||||||
|
*/
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimUserObjectionPayload {
|
||||||
|
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
|
||||||
|
objectionParts?: ClaimUserObjectionPart[];
|
||||||
|
|
||||||
|
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
|
||||||
|
newParts?: ClaimUserObjectionNewPart[];
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: () => new Date() })
|
||||||
|
submittedAt?: Date;
|
||||||
|
}
|
||||||
|
export const ClaimUserObjectionPayloadSchema =
|
||||||
|
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimResendRequest {
|
export class ClaimResendRequest {
|
||||||
@@ -117,6 +208,17 @@ export class ClaimResendRequest {
|
|||||||
|
|
||||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
resendCarParts?: any[];
|
resendCarParts?: any[];
|
||||||
|
|
||||||
|
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
fulfilledAt?: Date;
|
||||||
|
|
||||||
|
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||||
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
|
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
requestedByExpertId?: Types.ObjectId;
|
||||||
}
|
}
|
||||||
export const ClaimResendRequestSchema =
|
export const ClaimResendRequestSchema =
|
||||||
SchemaFactory.createForClass(ClaimResendRequest);
|
SchemaFactory.createForClass(ClaimResendRequest);
|
||||||
@@ -145,6 +247,9 @@ export class ClaimFileRating {
|
|||||||
|
|
||||||
@Prop({ type: Number, min: 0, max: 5 })
|
@Prop({ type: Number, min: 0, max: 5 })
|
||||||
guiltyVehicleIdentification?: number;
|
guiltyVehicleIdentification?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, min: 0, max: 5 })
|
||||||
|
botRating?: number;
|
||||||
}
|
}
|
||||||
export const ClaimFileRatingSchema =
|
export const ClaimFileRatingSchema =
|
||||||
SchemaFactory.createForClass(ClaimFileRating);
|
SchemaFactory.createForClass(ClaimFileRating);
|
||||||
@@ -165,6 +270,12 @@ export class ClaimPriceDrop {
|
|||||||
|
|
||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
sumOfSeverity?: number;
|
sumOfSeverity?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number })
|
||||||
|
coefficientYear?: number;
|
||||||
|
|
||||||
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
|
partLines?: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
|
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
|
||||||
|
|
||||||
@@ -182,8 +293,8 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimResendRequestSchema })
|
@Prop({ type: ClaimResendRequestSchema })
|
||||||
damageExpertResend?: ClaimResendRequest | null;
|
damageExpertResend?: ClaimResendRequest | null;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserObjectionSchema })
|
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||||
objection?: ClaimUserObjection;
|
objection?: ClaimUserObjectionPayload;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||||
userResendDocuments?: ClaimUserResendPayload;
|
userResendDocuments?: ClaimUserResendPayload;
|
||||||
@@ -191,11 +302,25 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimFileRatingSchema })
|
@Prop({ type: ClaimFileRatingSchema })
|
||||||
rating?: ClaimFileRating;
|
rating?: ClaimFileRating;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||||
|
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
|
||||||
|
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
|
||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
visitLocation?: string;
|
visitLocation?: string;
|
||||||
|
|
||||||
@Prop({ type: ClaimPriceDropSchema })
|
@Prop({ type: ClaimPriceDropSchema })
|
||||||
priceDrop?: ClaimPriceDrop;
|
priceDrop?: ClaimPriceDrop;
|
||||||
|
|
||||||
|
/** Damage expert profile at last factor validation (`damage-expert` collection). */
|
||||||
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
|
factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
|
|
||||||
|
/** Damage expert profile when in-person visit was requested (`damage-expert` collection). */
|
||||||
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
|
inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
}
|
}
|
||||||
export const ClaimEvaluationSchema =
|
export const ClaimEvaluationSchema =
|
||||||
SchemaFactory.createForClass(ClaimEvaluation);
|
SchemaFactory.createForClass(ClaimEvaluation);
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class ClaimOwner {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
clientId: Types.ObjectId;
|
||||||
}
|
}
|
||||||
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);
|
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { Types } from "mongoose";
|
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||||
import { AiImagesModel } from "./ai-image.schema";
|
import { AiImagesModel } from "./ai-image.schema";
|
||||||
import { CarGreenCardModel } from "./car-green-card.schema";
|
import { CarGreenCardModel } from "./car-green-card.schema";
|
||||||
import { ImageRequiredModel } from "./image-required.schema";
|
import { ImageRequiredModel } from "./image-required.schema";
|
||||||
@@ -20,6 +20,39 @@ export class CapturedImage {
|
|||||||
}
|
}
|
||||||
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
|
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
|
||||||
|
|
||||||
|
/** One row per selected damaged part (index-aligned with `damage.selectedParts`). */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class DamagedPartMediaV2Row {
|
||||||
|
@Prop({ type: Number })
|
||||||
|
id?: number;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
side?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
label_fa?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
catalogKey?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
fileName?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Date })
|
||||||
|
capturedAt?: Date;
|
||||||
|
}
|
||||||
|
export const DamagedPartMediaV2RowSchema =
|
||||||
|
SchemaFactory.createForClass(DamagedPartMediaV2Row);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimMedia {
|
export class ClaimMedia {
|
||||||
@Prop({ type: CarGreenCardModel })
|
@Prop({ type: CarGreenCardModel })
|
||||||
@@ -46,15 +79,10 @@ export class ClaimMedia {
|
|||||||
carAngles?: Map<string, CapturedImage>;
|
carAngles?: Map<string, CapturedImage>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Damaged parts captures
|
* V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
|
||||||
* Map of part key to captured image
|
* Legacy documents may store a plain object map keyed by part slug until migrated on write.
|
||||||
*/
|
*/
|
||||||
@Prop({
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
type: Map,
|
damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
|
||||||
of: CapturedImageSchema,
|
|
||||||
default: () => ({}),
|
|
||||||
})
|
|
||||||
damagedParts?: Map<string, CapturedImage>;
|
|
||||||
}
|
}
|
||||||
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);
|
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
|
import {
|
||||||
|
ExpertProfileSnapshot,
|
||||||
|
ExpertProfileSnapshotSchema,
|
||||||
|
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimActorLock {
|
export class ClaimActorLock {
|
||||||
@@ -12,9 +17,29 @@ export class ClaimActorLock {
|
|||||||
|
|
||||||
@Prop({ type: String, default: "damage_expert" })
|
@Prop({ type: String, default: "damage_expert" })
|
||||||
actorRole: "damage_expert" | "expert" | "admin";
|
actorRole: "damage_expert" | "expert" | "admin";
|
||||||
|
|
||||||
|
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||||
|
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||||
|
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||||
}
|
}
|
||||||
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
|
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
|
||||||
|
|
||||||
|
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class ClaimPreLockQueueSnapshot {
|
||||||
|
@Prop({ type: String, enum: ClaimStatus, required: true })
|
||||||
|
claimStatus: ClaimStatus;
|
||||||
|
|
||||||
|
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
|
||||||
|
currentStep: ClaimWorkflowStep;
|
||||||
|
|
||||||
|
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
|
||||||
|
nextStep?: ClaimWorkflowStep;
|
||||||
|
}
|
||||||
|
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
|
||||||
|
ClaimPreLockQueueSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimWorkflow {
|
export class ClaimWorkflow {
|
||||||
@Prop({ type: String, enum: ClaimWorkflowStep })
|
@Prop({ type: String, enum: ClaimWorkflowStep })
|
||||||
@@ -32,8 +57,22 @@ export class ClaimWorkflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
|
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
expiredAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: ClaimActorLockSchema })
|
@Prop({ type: ClaimActorLockSchema })
|
||||||
lockedBy?: ClaimActorLock;
|
lockedBy?: ClaimActorLock;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||||
|
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First damage expert who called review assign; kept after the 15m lock expires
|
||||||
|
* so no other expert can take the case while it remains in the expert queue.
|
||||||
|
*/
|
||||||
|
@Prop({ type: ClaimActorLockSchema })
|
||||||
|
assignedForReviewBy?: ClaimActorLock;
|
||||||
}
|
}
|
||||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
|
|||||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type";
|
import {
|
||||||
|
HistoryEvent,
|
||||||
|
HistoryEventSchema,
|
||||||
|
} from "src/request-management/entities/schema/historyEvent.type";
|
||||||
import {
|
import {
|
||||||
ClaimDamageSelection,
|
ClaimDamageSelection,
|
||||||
ClaimDamageSelectionSchema,
|
ClaimDamageSelectionSchema,
|
||||||
@@ -25,7 +28,15 @@ import {
|
|||||||
ClaimCaseSnapshot,
|
ClaimCaseSnapshot,
|
||||||
ClaimCaseSnapshotSchema,
|
ClaimCaseSnapshotSchema,
|
||||||
} from "./claim-case.snapshot.schema";
|
} from "./claim-case.snapshot.schema";
|
||||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
import {
|
||||||
|
ClaimWorkflow,
|
||||||
|
ClaimWorkflowSchema,
|
||||||
|
} from "./claim-case.workflow.schema";
|
||||||
|
import { UserClaimRating } from "./claim-request-management.schema";
|
||||||
|
import {
|
||||||
|
CaseInquiries,
|
||||||
|
CaseInquiriesSchema,
|
||||||
|
} from "src/common/schema/case-inquiries.schema";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class RequiredDocumentRef {
|
export class RequiredDocumentRef {
|
||||||
@@ -44,7 +55,8 @@ export class RequiredDocumentRef {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
uploadedAt?: Date;
|
uploadedAt?: Date;
|
||||||
}
|
}
|
||||||
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(RequiredDocumentRef);
|
export const RequiredDocumentRefSchema =
|
||||||
|
SchemaFactory.createForClass(RequiredDocumentRef);
|
||||||
|
|
||||||
@Schema({
|
@Schema({
|
||||||
collection: "claimCases",
|
collection: "claimCases",
|
||||||
@@ -66,7 +78,12 @@ export class ClaimCase {
|
|||||||
/**
|
/**
|
||||||
* Overall case status (user flow + expert flow progression)
|
* Overall case status (user flow + expert flow progression)
|
||||||
*/
|
*/
|
||||||
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED })
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
type: String,
|
||||||
|
enum: ClaimCaseStatus,
|
||||||
|
default: ClaimCaseStatus.CREATED,
|
||||||
|
})
|
||||||
status: ClaimCaseStatus;
|
status: ClaimCaseStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +92,12 @@ export class ClaimCase {
|
|||||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||||
claimStatus: ClaimStatus;
|
claimStatus: ClaimStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
|
||||||
|
*/
|
||||||
|
@Prop({ default: false })
|
||||||
|
blameDocumentResendPending?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link to the blame case that originated this claim.
|
* Link to the blame case that originated this claim.
|
||||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||||
@@ -85,6 +108,22 @@ export class ClaimCase {
|
|||||||
@Prop({ type: String, index: true })
|
@Prop({ type: String, index: true })
|
||||||
blameRequestNo?: string;
|
blameRequestNo?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when this claim was created by a field expert on behalf of the damaged
|
||||||
|
* party (expert-initiated IN_PERSON flow). Used to scope the expert's panel
|
||||||
|
* access to only their own initiated files.
|
||||||
|
*/
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||||
|
* Stored here so view/list access does not require an extra blame lookup.
|
||||||
|
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
|
||||||
|
*/
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
damagedPartyUserId?: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
||||||
workflow?: ClaimWorkflow;
|
workflow?: ClaimWorkflow;
|
||||||
|
|
||||||
@@ -112,6 +151,9 @@ export class ClaimCase {
|
|||||||
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
|
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
|
||||||
evaluation?: ClaimEvaluation;
|
evaluation?: ClaimEvaluation;
|
||||||
|
|
||||||
|
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
|
||||||
|
inquiries?: CaseInquiries;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional “read-optimized” copy of fields from blame/request side.
|
* Optional “read-optimized” copy of fields from blame/request side.
|
||||||
* Source of truth remains `blameRequestId`.
|
* Source of truth remains `blameRequestId`.
|
||||||
@@ -133,6 +175,15 @@ export class ClaimCase {
|
|||||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||||
history?: HistoryEvent[];
|
history?: HistoryEvent[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User satisfaction rating (damaged party), after the case is completed.
|
||||||
|
*/
|
||||||
|
@Prop({ type: UserClaimRating, required: false })
|
||||||
|
userRating?: UserClaimRating;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
createdByRegistrarId?: Types.ObjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy fields kept optional to simplify progressive migration.
|
* Legacy fields kept optional to simplify progressive migration.
|
||||||
* If you choose to migrate later, we can remove these.
|
* If you choose to migrate later, we can remove these.
|
||||||
@@ -143,5 +194,4 @@ export class ClaimCase {
|
|||||||
|
|
||||||
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
||||||
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
||||||
|
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export class SubmitReply {
|
|||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
userComment?: UserComment;
|
userComment?: UserComment;
|
||||||
|
|
||||||
|
/** Damage expert profile at reply time (`damage-expert` collection). */
|
||||||
|
@Prop({ type: Object })
|
||||||
|
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActorLockDetails {
|
export class ActorLockDetails {
|
||||||
@@ -130,6 +134,10 @@ export class ActorLockDetails {
|
|||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
actorId?: Types.ObjectId;
|
actorId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||||
|
@Prop({ type: Object })
|
||||||
|
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ResendCarPartsDto {
|
export class ResendCarPartsDto {
|
||||||
@@ -149,6 +157,10 @@ export class ClaimSubmitResend {
|
|||||||
|
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
resendCarParts: ResendCarPartsDto[];
|
resendCarParts: ResendCarPartsDto[];
|
||||||
|
|
||||||
|
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||||
|
@Prop({ type: Object })
|
||||||
|
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserResendDocuments {
|
export class UserResendDocuments {
|
||||||
@@ -360,6 +372,14 @@ export class ClaimRequestManagementModel {
|
|||||||
@Prop({ type: UserClaimRating, required: false })
|
@Prop({ type: UserClaimRating, required: false })
|
||||||
userRating?: UserClaimRating;
|
userRating?: UserClaimRating;
|
||||||
|
|
||||||
|
/** Latest damage expert profile when validating repair factors (V1). */
|
||||||
|
@Prop({ type: Object })
|
||||||
|
factorValidationExpertProfileSnapshot?: Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** Damage expert profile when in-person visit was requested (V1). */
|
||||||
|
@Prop({ type: Object })
|
||||||
|
damageExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
|
||||||
|
|
||||||
@Prop({ type: String, required: false })
|
@Prop({ type: String, required: false })
|
||||||
visitLocation?: string;
|
visitLocation?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Prop, Schema } from "@nestjs/mongoose";
|
import { Prop, Schema } from "@nestjs/mongoose";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
@Schema({ versionKey: false, _id: false })
|
@Schema({ versionKey: false, _id: false })
|
||||||
export class ImageRequiredModel {
|
export class ImageRequiredModel {
|
||||||
@@ -20,7 +20,7 @@ export class ImageRequiredModel {
|
|||||||
constructor(claimFile: any[]) {
|
constructor(claimFile: any[]) {
|
||||||
this.aroundTheCar.forEach((a) => {
|
this.aroundTheCar.forEach((a) => {
|
||||||
Object.assign(a, {
|
Object.assign(a, {
|
||||||
partId: uuidv4(),
|
partId: randomUUID(),
|
||||||
imageId: null,
|
imageId: null,
|
||||||
aiReport: {},
|
aiReport: {},
|
||||||
upload: false,
|
upload: false,
|
||||||
@@ -28,7 +28,7 @@ export class ImageRequiredModel {
|
|||||||
});
|
});
|
||||||
this.selectPartOfCar = claimFile.map((c, idx) =>
|
this.selectPartOfCar = claimFile.map((c, idx) =>
|
||||||
Object.assign(c, {
|
Object.assign(c, {
|
||||||
partId: uuidv4(),
|
partId: randomUUID(),
|
||||||
aiReport: {},
|
aiReport: {},
|
||||||
imageId: null,
|
imageId: null,
|
||||||
upload: false,
|
upload: false,
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname } from "node:path";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
import {
|
||||||
|
OuterPartCatalogItemDto,
|
||||||
|
SelectOuterPartsV2Dto,
|
||||||
|
SelectOuterPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-outer-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
SelectOtherPartsV2Dto,
|
||||||
|
SelectOtherPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-other-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
UploadRequiredDocumentV2Dto,
|
||||||
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
|
} from "./dto/upload-document-v2.dto";
|
||||||
|
import {
|
||||||
|
CapturePartV2Dto,
|
||||||
|
CapturePartV2ResponseDto,
|
||||||
|
} from "./dto/capture-part-v2.dto";
|
||||||
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expert-initiated claim flow that mirrors the normal user claim API
|
||||||
|
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
|
||||||
|
* claim pages by only swapping the route prefix:
|
||||||
|
*
|
||||||
|
* `v2/claim-request-management/*` -> `v2/expert-initiated/claim-request-management/*`
|
||||||
|
*
|
||||||
|
* The field expert fills the claim (parts, captures, documents) on behalf of the
|
||||||
|
* damaged party for a claim created from an expert-initiated IN_PERSON blame.
|
||||||
|
* After the expert submits the data, the file follows the exact normal review
|
||||||
|
* lifecycle: the damage expert prices it, and the owner can later object / resend
|
||||||
|
* from their own account through the normal user endpoints.
|
||||||
|
*/
|
||||||
|
@ApiTags("expert-initiated claim (mirror v2)")
|
||||||
|
@Controller("v2/expert-initiated/claim-request-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.FIELD_EXPERT)
|
||||||
|
export class ExpertInitiatedClaimMirrorController {
|
||||||
|
constructor(
|
||||||
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("create-from-blame/:blameRequestId")
|
||||||
|
@ApiParam({ name: "blameRequestId" })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
|
||||||
|
})
|
||||||
|
async createFromBlame(
|
||||||
|
@Param("blameRequestId") blameRequestId: string,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
|
||||||
|
blameRequestId,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("outer-parts-catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get outer parts catalog (V2)",
|
||||||
|
description:
|
||||||
|
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts catalog",
|
||||||
|
type: [OuterPartCatalogItemDto],
|
||||||
|
})
|
||||||
|
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
|
||||||
|
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("car-other-part")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get other (non-body) parts catalog",
|
||||||
|
description:
|
||||||
|
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Other parts catalog" })
|
||||||
|
async getCarOtherParts() {
|
||||||
|
const raw = await readFile(
|
||||||
|
`${process.cwd()}/src/static/car-part.json`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("branches/:insuranceId")
|
||||||
|
@ApiParam({
|
||||||
|
name: "insuranceId",
|
||||||
|
description: "Insurer client id (MongoDB ObjectId)",
|
||||||
|
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get insurer branches (V2)",
|
||||||
|
description:
|
||||||
|
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||||
|
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
|
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
|
insuranceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Expert selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||||
|
|
||||||
|
**Validations:**
|
||||||
|
- Claim must exist
|
||||||
|
- Current workflow step must be CLAIM_CREATED
|
||||||
|
- Parts array must contain at least 1 part
|
||||||
|
- No duplicate parts allowed
|
||||||
|
- Parts cannot be re-selected once submitted
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: SelectOuterPartsV2Dto,
|
||||||
|
description:
|
||||||
|
"Selected vehicle type + selected outer part IDs from catalog",
|
||||||
|
examples: {
|
||||||
|
example1: {
|
||||||
|
summary: "Sedan - minor front damage",
|
||||||
|
value: {
|
||||||
|
carType: "sedan",
|
||||||
|
selectedPartIds: [19, 21, 16],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example2: {
|
||||||
|
summary: "SUV - left side impact",
|
||||||
|
value: {
|
||||||
|
carType: "suv",
|
||||||
|
selectedPartIds: [102, 103, 104, 107],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example3: {
|
||||||
|
summary: "Hatchback - rear-end collision",
|
||||||
|
value: {
|
||||||
|
carType: "hatchback",
|
||||||
|
selectedPartIds: [225, 226, 210],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example4: {
|
||||||
|
summary: "Pickup - two sides + roof",
|
||||||
|
value: {
|
||||||
|
carType: "pickup",
|
||||||
|
selectedPartIds: [319, 312, 330],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts selected successfully",
|
||||||
|
type: SelectOuterPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Outer parts already selected" })
|
||||||
|
async selectOuterParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOuterPartsV2Dto,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-other-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Expert selects non-body damaged parts and provides bank information for payment on behalf of the damaged party.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
|
**Validations:**
|
||||||
|
- Claim must exist
|
||||||
|
- Current workflow step must be SELECT_OTHER_PARTS
|
||||||
|
- Bank information must not have been submitted previously
|
||||||
|
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||||
|
- National code must be exactly 10 digits
|
||||||
|
|
||||||
|
**Valid Other Parts (Optional):**
|
||||||
|
- engine, suspension, brake_system, electrical
|
||||||
|
- radiator, transmission, exhaust
|
||||||
|
- headlight, taillight, mirror, glass
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-required-document",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `other-parts-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
otherParts: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "array", items: { type: "string" } },
|
||||||
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
|
],
|
||||||
|
example: ["engine", "suspension"],
|
||||||
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Other parts and bank information saved successfully",
|
||||||
|
type: SelectOtherPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Bank information already submitted" })
|
||||||
|
async selectOtherParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
|
// Green-card photo is optional here — the helper no-ops on missing file.
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("capture-requirements/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get Capture Requirements (V2)",
|
||||||
|
description: `
|
||||||
|
**Get list of what needs to be captured:**
|
||||||
|
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
|
||||||
|
- Car angles (4 items: front, back, left, right)
|
||||||
|
- Damaged parts (based on selected outer parts)
|
||||||
|
|
||||||
|
Returns status of each item (uploaded/captured or not).
|
||||||
|
|
||||||
|
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture requirements retrieved successfully",
|
||||||
|
type: GetCaptureRequirementsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: "User is not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
async getCaptureRequirements(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
|
claimRequestId,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-document/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-documents",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Required Document (V2 - Step 5)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
|
- damaged_driving_license_front/back
|
||||||
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
|
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||||
|
|
||||||
|
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["documentKey", "file"],
|
||||||
|
properties: {
|
||||||
|
documentKey: {
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"damaged_driving_license_front",
|
||||||
|
"damaged_driving_license_back",
|
||||||
|
"damaged_chassis_number",
|
||||||
|
"damaged_engine_photo",
|
||||||
|
"damaged_car_card_front",
|
||||||
|
"damaged_car_card_back",
|
||||||
|
"damaged_metal_plate",
|
||||||
|
"guilty_driving_license_front",
|
||||||
|
"guilty_driving_license_back",
|
||||||
|
"guilty_car_card_front",
|
||||||
|
"guilty_car_card_back",
|
||||||
|
"guilty_metal_plate",
|
||||||
|
],
|
||||||
|
example: "damaged_driving_license_front",
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Document uploaded successfully",
|
||||||
|
type: UploadRequiredDocumentV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
@ApiResponse({ status: 409, description: "Document already uploaded" })
|
||||||
|
async uploadDocument(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UploadRequiredDocumentV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("capture-part/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-captures",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||||
|
|
||||||
|
**Capture types:**
|
||||||
|
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||||
|
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||||
|
|
||||||
|
**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["captureType", "captureKey", "file"],
|
||||||
|
properties: {
|
||||||
|
captureType: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["angle", "part"],
|
||||||
|
example: "angle",
|
||||||
|
},
|
||||||
|
captureKey: {
|
||||||
|
type: "string",
|
||||||
|
example: "front",
|
||||||
|
description:
|
||||||
|
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture saved successfully",
|
||||||
|
type: CapturePartV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
async capturePart(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: CapturePartV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
): Promise<CapturePartV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.capturePartV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("car-capture/:claimRequestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/car-capture-videos/",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `claim-video-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["file"],
|
||||||
|
properties: { file: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
|
||||||
|
description:
|
||||||
|
"Upload a walk-around video of the damaged car during the capture phase. Allowed at any point after outer parts are selected.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||||
|
async carCapture(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||||
|
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
|
claimRequestId,
|
||||||
|
file,
|
||||||
|
expert.sub,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname } from "node:path";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
import {
|
||||||
|
OuterPartCatalogItemDto,
|
||||||
|
SelectOuterPartsV2Dto,
|
||||||
|
SelectOuterPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-outer-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
SelectOtherPartsV2Dto,
|
||||||
|
SelectOtherPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-other-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
UploadRequiredDocumentV2Dto,
|
||||||
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
|
} from "./dto/upload-document-v2.dto";
|
||||||
|
import {
|
||||||
|
CapturePartV2Dto,
|
||||||
|
CapturePartV2ResponseDto,
|
||||||
|
} from "./dto/capture-part-v2.dto";
|
||||||
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar claim flow that mirrors the normal user claim API
|
||||||
|
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
|
||||||
|
* claim pages by only swapping the route prefix:
|
||||||
|
*
|
||||||
|
* `v2/claim-request-management/*` -> `v2/registrar/claim-request-management/*`
|
||||||
|
*
|
||||||
|
* The registrar fills the claim (parts, captures, documents) on behalf of the
|
||||||
|
* damaged party for a claim created from a registrar-initiated IN_PERSON blame.
|
||||||
|
* After submission the file follows the exact normal review lifecycle:
|
||||||
|
* the damage expert prices it, and the owner can later object/resend.
|
||||||
|
* The registrar's job ends here — no reviewing panel is needed.
|
||||||
|
*/
|
||||||
|
@ApiTags("registrar claim (mirror v2)")
|
||||||
|
@Controller("v2/registrar/claim-request-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.REGISTRAR)
|
||||||
|
export class RegistrarClaimMirrorController {
|
||||||
|
constructor(
|
||||||
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("create-from-blame/:blameRequestId")
|
||||||
|
@ApiParam({ name: "blameRequestId" })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Create claim from registrar-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
|
||||||
|
})
|
||||||
|
async createFromBlame(
|
||||||
|
@Param("blameRequestId") blameRequestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||||
|
blameRequestId,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("outer-parts-catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get outer parts catalog (V2)",
|
||||||
|
description:
|
||||||
|
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts catalog",
|
||||||
|
type: [OuterPartCatalogItemDto],
|
||||||
|
})
|
||||||
|
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
|
||||||
|
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("car-other-part")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get other (non-body) parts catalog",
|
||||||
|
description:
|
||||||
|
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Other parts catalog" })
|
||||||
|
async getCarOtherParts() {
|
||||||
|
const raw = await readFile(
|
||||||
|
`${process.cwd()}/src/static/car-part.json`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("branches/:insuranceId")
|
||||||
|
@ApiParam({
|
||||||
|
name: "insuranceId",
|
||||||
|
description: "Insurer client id (MongoDB ObjectId)",
|
||||||
|
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get insurer branches (V2)",
|
||||||
|
description:
|
||||||
|
"Returns branch list for a given insurer/client id so frontend can render branch options.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||||
|
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
|
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
|
insuranceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: SelectOuterPartsV2Dto,
|
||||||
|
description: "Selected vehicle type + selected outer part IDs from catalog",
|
||||||
|
examples: {
|
||||||
|
example1: {
|
||||||
|
summary: "Sedan - minor front damage",
|
||||||
|
value: { carType: "sedan", selectedPartIds: [19, 21, 16] },
|
||||||
|
},
|
||||||
|
example2: {
|
||||||
|
summary: "SUV - left side impact",
|
||||||
|
value: { carType: "suv", selectedPartIds: [102, 103, 104, 107] },
|
||||||
|
},
|
||||||
|
example3: {
|
||||||
|
summary: "Hatchback - rear-end collision",
|
||||||
|
value: { carType: "hatchback", selectedPartIds: [225, 226, 210] },
|
||||||
|
},
|
||||||
|
example4: {
|
||||||
|
summary: "Pickup - two sides + roof",
|
||||||
|
value: { carType: "pickup", selectedPartIds: [319, 312, 330] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts selected successfully",
|
||||||
|
type: SelectOuterPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Outer parts already selected" })
|
||||||
|
async selectOuterParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOuterPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-other-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
|
**Valid Other Parts (Optional):**
|
||||||
|
- engine, suspension, brake_system, electrical
|
||||||
|
- radiator, transmission, exhaust
|
||||||
|
- headlight, taillight, mirror, glass
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-required-document",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `other-parts-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
otherParts: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "array", items: { type: "string" } },
|
||||||
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
|
],
|
||||||
|
example: ["engine", "suspension"],
|
||||||
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Other parts and bank information saved successfully",
|
||||||
|
type: SelectOtherPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Bank information already submitted" })
|
||||||
|
async selectOtherParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("capture-requirements/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get Capture Requirements (V2)",
|
||||||
|
description: `
|
||||||
|
**Get list of what needs to be captured:**
|
||||||
|
- Required documents (10 remaining at the documents step for third-party)
|
||||||
|
- Car angles (4 items: front, back, left, right)
|
||||||
|
- Damaged parts (based on selected outer parts)
|
||||||
|
|
||||||
|
Returns status of each item (uploaded/captured or not).
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture requirements retrieved successfully",
|
||||||
|
type: GetCaptureRequirementsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: "Access denied" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
async getCaptureRequirements(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
|
claimRequestId,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-document/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-documents",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Required Document (V2 - Step 5)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
|
- damaged_driving_license_front/back
|
||||||
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
|
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||||
|
|
||||||
|
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["documentKey", "file"],
|
||||||
|
properties: {
|
||||||
|
documentKey: {
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"damaged_driving_license_front",
|
||||||
|
"damaged_driving_license_back",
|
||||||
|
"damaged_chassis_number",
|
||||||
|
"damaged_engine_photo",
|
||||||
|
"damaged_car_card_front",
|
||||||
|
"damaged_car_card_back",
|
||||||
|
"damaged_metal_plate",
|
||||||
|
"guilty_driving_license_front",
|
||||||
|
"guilty_driving_license_back",
|
||||||
|
"guilty_car_card_front",
|
||||||
|
"guilty_car_card_back",
|
||||||
|
"guilty_metal_plate",
|
||||||
|
],
|
||||||
|
example: "damaged_driving_license_front",
|
||||||
|
},
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Document uploaded successfully",
|
||||||
|
type: UploadRequiredDocumentV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
@ApiResponse({ status: 409, description: "Document already uploaded" })
|
||||||
|
async uploadDocument(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UploadRequiredDocumentV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("capture-part/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-captures",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||||
|
|
||||||
|
**Capture types:**
|
||||||
|
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||||
|
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||||
|
|
||||||
|
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["captureType", "captureKey", "file"],
|
||||||
|
properties: {
|
||||||
|
captureType: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["angle", "part"],
|
||||||
|
example: "angle",
|
||||||
|
},
|
||||||
|
captureKey: {
|
||||||
|
type: "string",
|
||||||
|
example: "front",
|
||||||
|
description:
|
||||||
|
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||||
|
},
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture saved successfully",
|
||||||
|
type: CapturePartV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
async capturePart(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: CapturePartV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<CapturePartV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.capturePartV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("car-capture/:claimRequestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/car-capture-videos/",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `claim-video-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["file"],
|
||||||
|
properties: { file: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
|
||||||
|
description:
|
||||||
|
"Upload a walk-around video of the damaged car during the capture phase.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||||
|
async carCapture(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||||
|
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
|
claimRequestId,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
137
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import { extname } from "path";
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiExcludeController } from "@nestjs/swagger";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
|
import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
|
||||||
|
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
|
||||||
|
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
|
||||||
|
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
|
||||||
|
@ApiExcludeController()
|
||||||
|
// @ApiTags("registrar-claim (v1)")
|
||||||
|
@Controller("registrar-claim")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.REGISTRAR)
|
||||||
|
export class RegistrarClaimV1Controller {
|
||||||
|
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
|
||||||
|
|
||||||
|
@Post("create-from-blame/:blameRequestId")
|
||||||
|
// @ApiParam({ name: "blameRequestId" })
|
||||||
|
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
|
||||||
|
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||||
|
blameRequestId,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("request/:claimRequestId")
|
||||||
|
getClaim(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||||
|
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||||
|
claimRequestId,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
|
// @ApiBody({ type: SelectOuterPartsV2Dto })
|
||||||
|
selectOuter(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOuterPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-other-parts/:claimRequestId")
|
||||||
|
// @ApiBody({ type: SelectOtherPartsV2Dto })
|
||||||
|
selectOther(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("capture-requirements/:claimRequestId")
|
||||||
|
getCapture(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||||
|
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
|
claimRequestId,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-document/:claimRequestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-documents",
|
||||||
|
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// @ApiOperation({ summary: "Registrar uploads required claim document" })
|
||||||
|
uploadDoc(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UploadRequiredDocumentV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("capture-part/:claimRequestId")
|
||||||
|
// @ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-captures",
|
||||||
|
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
capturePart(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: CapturePartV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.capturePartV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
97
src/client/client-external-inquiries.controller.ts
Normal file
97
src/client/client-external-inquiries.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Put,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { ClientService } from "./client.service";
|
||||||
|
import {
|
||||||
|
ClientExternalInquiriesCatalogDto,
|
||||||
|
ClientExternalInquiriesListDto,
|
||||||
|
ClientExternalInquiriesViewDto,
|
||||||
|
ExternalInquiryFlagsDto,
|
||||||
|
UpdateClientExternalInquiriesDto,
|
||||||
|
} from "./dto/client-external-inquiries.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-insurer external inquiry toggles (public for now — lock down when super-admin exists).
|
||||||
|
*/
|
||||||
|
@ApiTags("client-external-inquiries")
|
||||||
|
@Controller("client")
|
||||||
|
export class ClientExternalInquiriesController {
|
||||||
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
|
@Get("external-inquiries/catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "List supported external inquiry kinds and API paths",
|
||||||
|
description: "No auth (temporary). Describes global master switch + per-client flags.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesCatalogDto })
|
||||||
|
getCatalog(): ClientExternalInquiriesCatalogDto {
|
||||||
|
return this.clientService.getExternalInquiriesCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "List external inquiry settings for all insurers",
|
||||||
|
description:
|
||||||
|
"No auth (temporary). Returns stored flags and effective live flags (after global master switch).",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesListDto })
|
||||||
|
listAll(): Promise<ClientExternalInquiriesListDto> {
|
||||||
|
return this.clientService.listExternalInquirySettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get external inquiry settings for one insurer",
|
||||||
|
description: "No auth (temporary).",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
getOne(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.getExternalInquirySettings(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Replace external inquiry flags for one insurer",
|
||||||
|
description:
|
||||||
|
"No auth (temporary). Omitted inquiry keys default to `false` (mock). Global master switch still applies at runtime.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiBody({ type: ExternalInquiryFlagsDto })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
replace(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
@Body() body: ExternalInquiryFlagsDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.replaceExternalInquirySettings(clientId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Partially update external inquiry flags for one insurer",
|
||||||
|
description: "No auth (temporary). Only supplied flags are changed.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiBody({ type: UpdateClientExternalInquiriesDto })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
patch(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
@Body() body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.patchExternalInquirySettings(clientId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/client/client-panel.controller.ts
Normal file
71
src/client/client-panel.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { ClientService } from "./client.service";
|
||||||
|
import {
|
||||||
|
ClientSettingsPanelResponseDto,
|
||||||
|
UpdateClientSettingsDto,
|
||||||
|
} from "./dto/client-settings.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insurer (company) panel: per-tenant upload limits and CAR_BODY accident window.
|
||||||
|
* Scoped to the JWT `clientKey` — tenants cannot edit other clients.
|
||||||
|
*/
|
||||||
|
@Controller("client-panel")
|
||||||
|
@ApiTags("insurer-client-panel")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.COMPANY)
|
||||||
|
export class ClientPanelController {
|
||||||
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
|
@Get("settings")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get tenant settings (media limits & CAR_BODY accident window)",
|
||||||
|
description:
|
||||||
|
"Returns configured overrides, effective values (with system defaults), and route upload ceilings for the authenticated insurer's client.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||||
|
async getSettings(
|
||||||
|
@CurrentUser() insurer: { clientKey?: string },
|
||||||
|
): Promise<ClientSettingsPanelResponseDto> {
|
||||||
|
if (!insurer?.clientKey) {
|
||||||
|
throw new UnauthorizedException("Insurer client key is missing.");
|
||||||
|
}
|
||||||
|
return this.clientService.getPanelSettings(insurer.clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("settings")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Update tenant settings",
|
||||||
|
description:
|
||||||
|
"Partial update of `settings.carBodyAccidentMaxAgeDays` and/or `settings.media` (video, image, voice). " +
|
||||||
|
"Only fields sent in the body are changed. `maxBytes` cannot exceed the route ceiling returned by GET.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||||
|
async updateSettings(
|
||||||
|
@CurrentUser() insurer: { clientKey?: string },
|
||||||
|
@Body() body: UpdateClientSettingsDto,
|
||||||
|
): Promise<ClientSettingsPanelResponseDto> {
|
||||||
|
if (!insurer?.clientKey) {
|
||||||
|
throw new UnauthorizedException("Insurer client key is missing.");
|
||||||
|
}
|
||||||
|
return this.clientService.updatePanelSettings(insurer.clientKey, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
Body,
|
ApiBody,
|
||||||
Controller,
|
ApiOperation,
|
||||||
Get,
|
ApiResponse,
|
||||||
Post,
|
ApiTags,
|
||||||
UseGuards,
|
} from "@nestjs/swagger";
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
import { ClientDto } from "./dto/create-client.dto";
|
import { ClientDto } from "./dto/create-client.dto";
|
||||||
|
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
|
||||||
|
|
||||||
@Controller("client")
|
@Controller("client")
|
||||||
@ApiTags("client-management")
|
@ApiTags("client-management")
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
constructor(private readonly clientService: ClientService) {}
|
constructor(
|
||||||
|
private readonly clientService: ClientService,
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(GlobalGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
async addClient(@Body() client: ClientDto) {
|
async addClient(@Body() client: ClientDto) {
|
||||||
return await this.clientService.addClient(client);
|
return await this.clientService.addClient(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(GlobalGuard)
|
|
||||||
async getClient(@CurrentUser() user) {
|
async getClient(@CurrentUser() user) {
|
||||||
return await this.clientService.getClients();
|
return await this.clientService.getClients();
|
||||||
}
|
}
|
||||||
@@ -34,4 +34,33 @@ export class ClientController {
|
|||||||
async getClientList(@CurrentUser() user) {
|
async getClientList(@CurrentUser() user) {
|
||||||
return await this.clientService.getClientList();
|
return await this.clientService.getClientList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
|
||||||
|
@Patch("external-inquiries-live")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Enable or disable live external inquiries",
|
||||||
|
description:
|
||||||
|
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: SetExternalInquiriesLiveDto,
|
||||||
|
examples: {
|
||||||
|
enableLive: {
|
||||||
|
summary: "Enable live inquiries",
|
||||||
|
description: "Call real SandHub/Tejarat HTTP APIs.",
|
||||||
|
value: { enabled: true },
|
||||||
|
},
|
||||||
|
disableLive: {
|
||||||
|
summary: "Disable live inquiries (mock mode)",
|
||||||
|
description: "Use mocked inquiry responses; flows continue without external connectivity.",
|
||||||
|
value: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||||
|
async setExternalInquiriesLive(@Body() body?: SetExternalInquiriesLiveDto) {
|
||||||
|
return this.systemSettingsService.updateSettings({
|
||||||
|
externalApis: { sandHubUseLiveApi: body?.enabled === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
import { ClientController } from "./client.controller";
|
import { ClientController } from "./client.controller";
|
||||||
|
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
|
||||||
|
import { ClientPanelController } from "./client-panel.controller";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
||||||
@@ -9,6 +13,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SystemSettingsModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClientModel.name, schema: ClientDbSchema },
|
{ name: ClientModel.name, schema: ClientDbSchema },
|
||||||
{
|
{
|
||||||
@@ -17,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ClientController],
|
controllers: [
|
||||||
providers: [ClientService, ClientDbService, BranchDbService],
|
ClientController,
|
||||||
exports: [ClientService, ClientDbService, BranchDbService],
|
ClientPanelController,
|
||||||
|
ClientExternalInquiriesController,
|
||||||
|
],
|
||||||
|
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||||
|
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||||
})
|
})
|
||||||
export class ClientModule {}
|
export class ClientModule {}
|
||||||
|
|||||||
@@ -1,32 +1,148 @@
|
|||||||
import { BadGatewayException, GoneException, Injectable } from "@nestjs/common";
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
BadRequestException,
|
||||||
|
GoneException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import {
|
import {
|
||||||
ClientDto,
|
ClientDto,
|
||||||
ClientDtoRs,
|
ClientDtoRs,
|
||||||
ClientLists,
|
ClientLists,
|
||||||
} from "src/client/dto/create-client.dto";
|
} from "src/client/dto/create-client.dto";
|
||||||
|
import {
|
||||||
|
type ClientSettingsPanelResponseDto,
|
||||||
|
UpdateClientSettingsDto,
|
||||||
|
} from "src/client/dto/client-settings.dto";
|
||||||
|
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
|
import {
|
||||||
|
ClientExternalInquiriesCatalogDto,
|
||||||
|
ClientExternalInquiriesListDto,
|
||||||
|
ClientExternalInquiriesViewDto,
|
||||||
|
toClientExternalInquiriesView,
|
||||||
|
UpdateClientExternalInquiriesDto,
|
||||||
|
} from "./dto/client-external-inquiries.dto";
|
||||||
|
import {
|
||||||
|
EXTERNAL_INQUIRY_TYPES,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide default applied when a client document has no
|
||||||
|
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
|
||||||
|
* gate is enforced even for older / unconfigured clients.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media kinds we enforce upload-size bounds for. Each kind has its own
|
||||||
|
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
|
||||||
|
* per-client override under `settings.media.<kind>`.
|
||||||
|
*/
|
||||||
|
export type MediaKind =
|
||||||
|
| "video"
|
||||||
|
| "image"
|
||||||
|
| "voice"
|
||||||
|
| "chassisNumber"
|
||||||
|
| "carPlate";
|
||||||
|
|
||||||
|
export interface MediaLimits {
|
||||||
|
/** Inclusive lower bound. `0` allows any size from zero up. */
|
||||||
|
minBytes: number;
|
||||||
|
/** Inclusive upper bound. */
|
||||||
|
maxBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System defaults applied when a client (or kind) has no explicit override.
|
||||||
|
*
|
||||||
|
* Important: each upload route also has a `multer.limits.fileSize` hard
|
||||||
|
* ceiling — those route ceilings are the absolute maximum the API can
|
||||||
|
* receive. Per-client `maxBytes` cannot legitimately go above its route's
|
||||||
|
* multer ceiling, so the defaults below are kept at-or-below those.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MEDIA_MIN_BYTES = 10; // 10 Byte
|
||||||
|
export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
|
||||||
|
video: {
|
||||||
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||||
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||||
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||||
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
},
|
||||||
|
carPlate: {
|
||||||
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||||
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
},
|
||||||
|
chassisNumber: {
|
||||||
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||||
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
|
||||||
|
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
||||||
|
video: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
image: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
voice: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
carPlate: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
chassisNumber: DEFAULT_MEDIA_MAX_BYTES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
|
||||||
|
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365;
|
||||||
|
|
||||||
|
const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientService {
|
export class ClientService {
|
||||||
constructor(private readonly clientDbService: ClientDbService) {}
|
constructor(
|
||||||
|
private readonly clientDbService: ClientDbService,
|
||||||
|
private readonly externalInquirySettingsService: ExternalInquirySettingsService,
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
) {}
|
||||||
async addClient(client: ClientDto): Promise<ClientDtoRs> {
|
async addClient(client: ClientDto): Promise<ClientDtoRs> {
|
||||||
try {
|
try {
|
||||||
|
const smsApiKey = client.property?.smsApiKey?.trim();
|
||||||
const newClient = await this.clientDbService.create({
|
const newClient = await this.clientDbService.create({
|
||||||
clientCode: client.clientCode,
|
clientCode: client.clientCode,
|
||||||
|
|
||||||
clientName: {
|
clientName: {
|
||||||
persian: client.clientName.persian,
|
persian:
|
||||||
english: client.clientName.english || null,
|
typeof client.clientName === "string"
|
||||||
|
? client.clientName
|
||||||
|
: client.clientName?.persian,
|
||||||
|
|
||||||
|
english:
|
||||||
|
typeof client.clientName === "string"
|
||||||
|
? null
|
||||||
|
: (client.clientName?.english ?? null),
|
||||||
},
|
},
|
||||||
property: {
|
|
||||||
smsApiKey: client.property.smsApiKey || null,
|
...(smsApiKey ? { property: { smsApiKey } } : {}),
|
||||||
},
|
|
||||||
useExpertMode: client.useExpertMode || null,
|
useExpertMode: client.useExpertMode ?? null,
|
||||||
});
|
});
|
||||||
if (newClient) return new ClientDtoRs(newClient);
|
|
||||||
else throw new GoneException("database not connected");
|
if (!newClient) {
|
||||||
|
throw new GoneException("database not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClientDtoRs(newClient);
|
||||||
} catch (er) {
|
} catch (er) {
|
||||||
throw new BadGatewayException(er.errors);
|
console.error("ADD CLIENT ERROR:", er);
|
||||||
|
throw er;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +158,38 @@ export class ClientService {
|
|||||||
return await this.clientDbService.find({ clientCode: companyCode });
|
return await this.clientDbService.find({ clientCode: companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a client by insurer company code from an external inquiry.
|
||||||
|
* Creates the client when missing so inquiry flows don't fail on unknown codes.
|
||||||
|
*/
|
||||||
|
async findOrCreateClientByCompanyCode(
|
||||||
|
companyCode: number | string | null | undefined,
|
||||||
|
companyName: string | null | undefined,
|
||||||
|
) {
|
||||||
|
const name = typeof companyName === "string" ? companyName.trim() : "";
|
||||||
|
const code = Number(companyCode);
|
||||||
|
if (!name || !Number.isFinite(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = await this.clientDbService.find({ clientCode: code });
|
||||||
|
if (client) return client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.addClient({
|
||||||
|
clientName: { persian: name, english: null },
|
||||||
|
clientCode: code,
|
||||||
|
useExpertMode: "legal",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client = await this.clientDbService.find({ clientCode: code });
|
||||||
|
if (client) return client;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clientDbService.find({ clientCode: code });
|
||||||
|
}
|
||||||
|
|
||||||
async getClients(): Promise<ClientDtoRs[]> {
|
async getClients(): Promise<ClientDtoRs[]> {
|
||||||
const clients = await this.clientDbService.findAll();
|
const clients = await this.clientDbService.findAll();
|
||||||
const show = clients.map((c) => new ClientDtoRs(c));
|
const show = clients.map((c) => new ClientDtoRs(c));
|
||||||
@@ -53,4 +201,282 @@ export class ClientService {
|
|||||||
const list = client.map((element) => new ClientLists(element));
|
const list = client.map((element) => new ClientLists(element));
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the per-client byte bounds for a media kind. Missing bounds fall
|
||||||
|
* back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it
|
||||||
|
* doesn't resolve to a client document) the defaults are returned.
|
||||||
|
*
|
||||||
|
* The returned object is always fully populated — callers can compare
|
||||||
|
* directly against `file.size`.
|
||||||
|
*/
|
||||||
|
async getMediaLimits(
|
||||||
|
clientId: string | Types.ObjectId | null | undefined,
|
||||||
|
kind: MediaKind,
|
||||||
|
): Promise<MediaLimits> {
|
||||||
|
const defaults = DEFAULT_MEDIA_LIMITS[kind];
|
||||||
|
if (!clientId) return { ...defaults };
|
||||||
|
|
||||||
|
const idString = String(clientId);
|
||||||
|
if (!Types.ObjectId.isValid(idString)) return { ...defaults };
|
||||||
|
|
||||||
|
const client = await this.clientDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(idString),
|
||||||
|
});
|
||||||
|
const override = client?.settings?.media?.[kind];
|
||||||
|
const minBytes =
|
||||||
|
typeof override?.minBytes === "number" &&
|
||||||
|
Number.isFinite(override.minBytes) &&
|
||||||
|
override.minBytes >= 0
|
||||||
|
? override.minBytes
|
||||||
|
: defaults.minBytes;
|
||||||
|
const maxBytes =
|
||||||
|
typeof override?.maxBytes === "number" &&
|
||||||
|
Number.isFinite(override.maxBytes) &&
|
||||||
|
override.maxBytes > 0
|
||||||
|
? override.maxBytes
|
||||||
|
: defaults.maxBytes;
|
||||||
|
return { minBytes, maxBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the per-client CAR_BODY accident-age window (in days).
|
||||||
|
*
|
||||||
|
* Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when:
|
||||||
|
* - no client id is supplied,
|
||||||
|
* - the id is malformed,
|
||||||
|
* - the client document is missing,
|
||||||
|
* - the client has no `settings.carBodyAccidentMaxAgeDays` configured,
|
||||||
|
* - or the configured value is not a positive finite number.
|
||||||
|
*/
|
||||||
|
async getCarBodyAccidentMaxAgeDays(
|
||||||
|
clientId?: string | Types.ObjectId | null,
|
||||||
|
): Promise<number> {
|
||||||
|
if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||||
|
|
||||||
|
const idString = String(clientId);
|
||||||
|
if (!Types.ObjectId.isValid(idString)) {
|
||||||
|
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await this.clientDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(idString),
|
||||||
|
});
|
||||||
|
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
|
||||||
|
if (
|
||||||
|
typeof configured === "number" &&
|
||||||
|
Number.isFinite(configured) &&
|
||||||
|
configured > 0
|
||||||
|
) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveClientObjectId(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
): Types.ObjectId {
|
||||||
|
const raw = String(clientKey ?? "").trim();
|
||||||
|
if (!Types.ObjectId.isValid(raw)) {
|
||||||
|
throw new BadRequestException("Invalid client key");
|
||||||
|
}
|
||||||
|
return new Types.ObjectId(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadClientOrThrow(clientKey: string | Types.ObjectId) {
|
||||||
|
const id = this.resolveClientObjectId(clientKey);
|
||||||
|
const client = await this.clientDbService.findOne({ _id: id });
|
||||||
|
if (!client) {
|
||||||
|
throw new NotFoundException("Client not found");
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private configuredMediaLimits(
|
||||||
|
stored: ClientMediaLimits | undefined,
|
||||||
|
): { minBytes?: number; maxBytes?: number } | null {
|
||||||
|
if (!stored) return null;
|
||||||
|
const hasMin = typeof stored.minBytes === "number";
|
||||||
|
const hasMax = typeof stored.maxBytes === "number";
|
||||||
|
if (!hasMin && !hasMax) return null;
|
||||||
|
return {
|
||||||
|
...(hasMin ? { minBytes: stored.minBytes } : {}),
|
||||||
|
...(hasMax ? { maxBytes: stored.maxBytes } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateMediaLimitsPatch(
|
||||||
|
kind: MediaKind,
|
||||||
|
patch: { minBytes?: number; maxBytes?: number },
|
||||||
|
): { minBytes?: number; maxBytes?: number } {
|
||||||
|
const defaults = DEFAULT_MEDIA_LIMITS[kind];
|
||||||
|
const routeMax = MEDIA_ROUTE_MAX_BYTES[kind];
|
||||||
|
const minBytes =
|
||||||
|
patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes;
|
||||||
|
const maxBytes =
|
||||||
|
patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes;
|
||||||
|
|
||||||
|
if (maxBytes > routeMax) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (minBytes > maxBytes) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: { minBytes?: number; maxBytes?: number } = {};
|
||||||
|
if (patch.minBytes !== undefined) out.minBytes = patch.minBytes;
|
||||||
|
if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPanelSettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
): Promise<ClientSettingsPanelResponseDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const clientId = client._id.toString();
|
||||||
|
|
||||||
|
const configuredDays = client.settings?.carBodyAccidentMaxAgeDays;
|
||||||
|
const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id);
|
||||||
|
|
||||||
|
const media = {} as ClientSettingsPanelResponseDto["media"];
|
||||||
|
for (const kind of MEDIA_KINDS) {
|
||||||
|
const effective = await this.getMediaLimits(client._id, kind);
|
||||||
|
media[kind] = {
|
||||||
|
configured: this.configuredMediaLimits(client.settings?.media?.[kind]),
|
||||||
|
effective,
|
||||||
|
systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] },
|
||||||
|
routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
carBodyAccidentMaxAgeDays: {
|
||||||
|
configured:
|
||||||
|
typeof configuredDays === "number" && Number.isFinite(configuredDays)
|
||||||
|
? configuredDays
|
||||||
|
: null,
|
||||||
|
effective: effectiveDays,
|
||||||
|
systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS,
|
||||||
|
minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN,
|
||||||
|
maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX,
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePanelSettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
body: UpdateClientSettingsDto,
|
||||||
|
): Promise<ClientSettingsPanelResponseDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body.carBodyAccidentMaxAgeDays !== undefined) {
|
||||||
|
$set["settings.carBodyAccidentMaxAgeDays"] =
|
||||||
|
body.carBodyAccidentMaxAgeDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.media) {
|
||||||
|
for (const kind of MEDIA_KINDS) {
|
||||||
|
const patch = body.media[kind];
|
||||||
|
if (!patch) continue;
|
||||||
|
const validated = this.validateMediaLimitsPatch(kind, patch);
|
||||||
|
if (validated.minBytes !== undefined) {
|
||||||
|
$set[`settings.media.${kind}.minBytes`] = validated.minBytes;
|
||||||
|
}
|
||||||
|
if (validated.maxBytes !== undefined) {
|
||||||
|
$set[`settings.media.${kind}.maxBytes`] = validated.maxBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys($set).length === 0) {
|
||||||
|
throw new BadRequestException("No settings fields to update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||||
|
client._id.toString(),
|
||||||
|
{ $set },
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException("Client not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPanelSettings(client._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
|
||||||
|
return {
|
||||||
|
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
|
||||||
|
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
|
||||||
|
perClientSettingPath: "/client/{clientId}/external-inquiries",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
const clients = await this.clientDbService.findAll();
|
||||||
|
return {
|
||||||
|
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
return toClientExternalInquiriesView(client, global);
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
$set[`settings.externalInquiries.${key}`] = flags[key];
|
||||||
|
}
|
||||||
|
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||||
|
client._id.toString(),
|
||||||
|
{ $set },
|
||||||
|
);
|
||||||
|
if (!updated) throw new NotFoundException("Client not found");
|
||||||
|
this.externalInquirySettingsService.invalidateClientCache(
|
||||||
|
client._id.toString(),
|
||||||
|
);
|
||||||
|
return this.getExternalInquirySettings(client._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
$set[`settings.externalInquiries.${key}`] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys($set).length === 0) {
|
||||||
|
throw new BadRequestException("No external inquiry flags to update.");
|
||||||
|
}
|
||||||
|
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||||
|
client._id.toString(),
|
||||||
|
{ $set },
|
||||||
|
);
|
||||||
|
if (!updated) throw new NotFoundException("Client not found");
|
||||||
|
this.externalInquirySettingsService.invalidateClientCache(
|
||||||
|
client._id.toString(),
|
||||||
|
);
|
||||||
|
return this.getExternalInquirySettings(client._id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
EXTERNAL_INQUIRY_TYPES,
|
||||||
|
ExternalInquiryFlags,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { IsBoolean, IsMongoId, IsOptional, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Third-party plate / policy block inquiry (Tejarat or ESG policyByPlate).",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
thirdPartyPlate: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "CAR_BODY (badane) plate inquiry.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
carBodyPlate: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Personal identity inquiry (national code + birth date).",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
personalIdentity: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Sheba account validation.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
sheba: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Driving licence check.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
drivingLicense: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Vehicle ownership validation.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
carOwnership: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateClientExternalInquiriesDto extends PartialType(
|
||||||
|
ExternalInquiryFlagsDto,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesViewDto {
|
||||||
|
@ApiProperty({ example: "664a1b2c3d4e5f6789012345" })
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8 })
|
||||||
|
clientCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "بیمه پارسیان" })
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Global master switch from `system_settings.externalApis.sandHubUseLiveApi`. When false, all inquiries use mocks regardless of these flags.",
|
||||||
|
})
|
||||||
|
globalSandHubLiveEnabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ExternalInquiryFlagsDto })
|
||||||
|
externalInquiries: ExternalInquiryFlags;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Effective live flags after applying the global master switch.",
|
||||||
|
type: ExternalInquiryFlagsDto,
|
||||||
|
})
|
||||||
|
effectiveLive: ExternalInquiryFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesListDto {
|
||||||
|
@ApiProperty({ type: [ClientExternalInquiriesViewDto] })
|
||||||
|
items: ClientExternalInquiriesViewDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesCatalogDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: EXTERNAL_INQUIRY_TYPES,
|
||||||
|
isArray: true,
|
||||||
|
description: "Supported inquiry kinds stored on each client document.",
|
||||||
|
})
|
||||||
|
inquiryTypes: readonly string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Global master switch path: PATCH /system-settings or PATCH /client/external-inquiries-live",
|
||||||
|
})
|
||||||
|
globalSettingPath: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Per-insurer CRUD base path (public for now).",
|
||||||
|
example: "/client/{clientId}/external-inquiries",
|
||||||
|
})
|
||||||
|
perClientSettingPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a view DTO from a lean client document. */
|
||||||
|
export function toClientExternalInquiriesView(
|
||||||
|
client: {
|
||||||
|
_id?: unknown;
|
||||||
|
clientCode?: number;
|
||||||
|
clientName?: { persian?: string; english?: string } | string;
|
||||||
|
settings?: { externalInquiries?: Partial<ExternalInquiryFlags> };
|
||||||
|
},
|
||||||
|
globalSandHubLiveEnabled: boolean,
|
||||||
|
): ClientExternalInquiriesViewDto {
|
||||||
|
const flags = mergeExternalInquiryFlags(client.settings?.externalInquiries);
|
||||||
|
const effectiveLive = {} as ExternalInquiryFlags;
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
effectiveLive[key] = globalSandHubLiveEnabled && flags[key] === true;
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
typeof client.clientName === "string"
|
||||||
|
? client.clientName
|
||||||
|
: client.clientName?.persian ||
|
||||||
|
client.clientName?.english ||
|
||||||
|
String(client.clientCode ?? "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: String(client._id ?? ""),
|
||||||
|
clientCode: Number(client.clientCode ?? 0),
|
||||||
|
clientName: name,
|
||||||
|
globalSandHubLiveEnabled,
|
||||||
|
externalInquiries: flags,
|
||||||
|
effectiveLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientIdParamDto {
|
||||||
|
@ApiProperty({ description: "Mongo ObjectId of the insurer client document" })
|
||||||
|
@IsMongoId()
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional nested patch used internally when validating partial bodies. */
|
||||||
|
export class ExternalInquiryFlagsPatchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdateClientExternalInquiriesDto)
|
||||||
|
externalInquiries?: UpdateClientExternalInquiriesDto;
|
||||||
|
}
|
||||||
131
src/client/dto/client-settings.dto.ts
Normal file
131
src/client/dto/client-settings.dto.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import type { MediaKind } from "../client.service";
|
||||||
|
|
||||||
|
export class ClientMediaLimitsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Minimum upload size in bytes (inclusive). Omit to keep default.",
|
||||||
|
example: 5120,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minBytes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Maximum upload size in bytes (inclusive). Cannot exceed route ceiling.",
|
||||||
|
example: 8388608,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientMediaSettingsDto {
|
||||||
|
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientMediaLimitsDto)
|
||||||
|
video?: ClientMediaLimitsDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientMediaLimitsDto)
|
||||||
|
image?: ClientMediaLimitsDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientMediaLimitsDto)
|
||||||
|
voice?: ClientMediaLimitsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateClientSettingsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Max days between CAR_BODY accident date and submission. Omit to leave unchanged.",
|
||||||
|
example: 7,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 365,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
carBodyAccidentMaxAgeDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: ClientMediaSettingsDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientMediaSettingsDto)
|
||||||
|
media?: ClientMediaSettingsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaLimitsResolvedDto {
|
||||||
|
@ApiProperty({ example: 5120 })
|
||||||
|
minBytes: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8388608 })
|
||||||
|
maxBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaKindSettingsViewDto {
|
||||||
|
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||||
|
configured: { minBytes?: number; maxBytes?: number } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||||
|
effective: MediaLimitsResolvedDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||||
|
systemDefault: MediaLimitsResolvedDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Hard multer ceiling for this kind on upload routes (bytes)",
|
||||||
|
example: 10485760,
|
||||||
|
})
|
||||||
|
routeMaxBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarBodyAccidentWindowViewDto {
|
||||||
|
@ApiPropertyOptional({ example: 14, nullable: true })
|
||||||
|
configured: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 14 })
|
||||||
|
effective: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 7 })
|
||||||
|
systemDefault: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
minDays: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 365 })
|
||||||
|
maxDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientSettingsPanelResponseDto {
|
||||||
|
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: CarBodyAccidentWindowViewDto })
|
||||||
|
carBodyAccidentMaxAgeDays: CarBodyAccidentWindowViewDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Per-kind upload bounds (video / image / voice)",
|
||||||
|
example: {
|
||||||
|
video: {},
|
||||||
|
image: {},
|
||||||
|
voice: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
media: Record<MediaKind, MediaKindSettingsViewDto>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsString, IsNotEmpty, IsOptional } from "class-validator";
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
export class CreateBranchDto {
|
export class CreateBranchDto {
|
||||||
@ApiProperty({ example: "شهرک غرب" })
|
@ApiProperty({ example: "شهرک غرب" })
|
||||||
@@ -31,4 +37,18 @@ export class CreateBranchDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: true, default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
example: "2025-01-01T00:00:00.000Z",
|
||||||
|
description: "Branch activity start datetime (ISO string)",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
activityStartDate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,51 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
|
|
||||||
class ClientName {
|
class ClientName {
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
persian: string;
|
persian: string;
|
||||||
|
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
english: string;
|
english: string;
|
||||||
}
|
}
|
||||||
class Property {
|
class Property {
|
||||||
@ApiProperty({})
|
@ApiPropertyOptional({})
|
||||||
smsApiKey: string;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
smsApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ClientDto {
|
export class ClientDto {
|
||||||
@ApiProperty({ required: true })
|
@ApiProperty({ required: true })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientName)
|
||||||
clientName: ClientName;
|
clientName: ClientName;
|
||||||
|
|
||||||
@ApiProperty({ required: true })
|
@ApiProperty({ required: true })
|
||||||
|
@IsNumber()
|
||||||
clientCode: number;
|
clientCode: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
property: Property;
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Property)
|
||||||
|
property?: Property;
|
||||||
|
|
||||||
@ApiProperty({ examples: ["legal", "genuine"] })
|
@ApiProperty({ examples: ["legal", "genuine"] })
|
||||||
|
@IsIn(["legal", "genuine"])
|
||||||
useExpertMode: "legal" | "genuine";
|
useExpertMode: "legal" | "genuine";
|
||||||
}
|
}
|
||||||
export class ClientDtoRs {
|
export class ClientDtoRs {
|
||||||
@@ -35,6 +55,9 @@ export class ClientDtoRs {
|
|||||||
useExpertsMode: string;
|
useExpertsMode: string;
|
||||||
constructor(readonly client) {
|
constructor(readonly client) {
|
||||||
this.persian = client.clientName.persian;
|
this.persian = client.clientName.persian;
|
||||||
|
this.english = client.clientName.english;
|
||||||
|
this.clientId = client._id;
|
||||||
|
this.useExpertsMode = client.useExpertMode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export class SetExternalInquiriesLiveDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"When true, SandHub/Tejarat live HTTP inquiries run. When false, mocked inquiry data is used.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
@@ -28,6 +28,70 @@ export class BranchDbService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllWithFilters(
|
||||||
|
insuranceId: string,
|
||||||
|
opts?: {
|
||||||
|
search?: string;
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<BranchModel[]> {
|
||||||
|
const filter: any = { clientKey: new Types.ObjectId(insuranceId) };
|
||||||
|
|
||||||
|
if (typeof opts?.isActive === "boolean") {
|
||||||
|
filter.isActive = opts.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.from || opts?.to) {
|
||||||
|
filter.$or = [
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
...(opts.from ? { $gte: opts.from } : {}),
|
||||||
|
...(opts.to ? { $lte: opts.to } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityStartDate: {
|
||||||
|
...(opts.from ? { $gte: opts.from } : {}),
|
||||||
|
...(opts.to ? { $lte: opts.to } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.search?.trim()) {
|
||||||
|
const q = opts.search.trim();
|
||||||
|
const rx = new RegExp(q, "i");
|
||||||
|
filter.$and = [
|
||||||
|
...(filter.$and || []),
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ name: rx },
|
||||||
|
{ code: rx },
|
||||||
|
{ city: rx },
|
||||||
|
{ state: rx },
|
||||||
|
{ address: rx },
|
||||||
|
{ phoneNumber: rx },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.branchModel.find(filter).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIds(ids: Types.ObjectId[]) {
|
||||||
|
return this.branchModel
|
||||||
|
.find({ _id: { $in: ids } })
|
||||||
|
.select({ _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, isActive: 1, activityStartDate: 1, createdAt: 1, updatedAt: 1 })
|
||||||
|
.lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdAndUpdate(id: string, update: any): Promise<BranchModel | null> {
|
||||||
|
return this.branchModel.findByIdAndUpdate(id, update, { new: true }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<BranchModel | null> {
|
async findById(id: string): Promise<BranchModel | null> {
|
||||||
return this.branchModel.findById(id).lean();
|
return this.branchModel.findById(id).lean();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
import { FilterQuery, Model } from "mongoose";
|
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
||||||
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientDbService {
|
export class ClientDbService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ClientDbService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(ClientModel.name)
|
@InjectModel(ClientModel.name)
|
||||||
private readonly clientModel: Model<ClientModel>,
|
private readonly clientModel: Model<ClientModel>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.dropLegacyPropertyUniqueIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `property` was once declared `unique: true` in the schema. Production keeps
|
||||||
|
* `autoIndex: false`, so the stale unique index remained and rejected a second
|
||||||
|
* client with a missing/null SMS key (E11000 duplicate key).
|
||||||
|
*/
|
||||||
|
private async dropLegacyPropertyUniqueIndex(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const indexes = await this.clientModel.collection.indexes();
|
||||||
|
for (const index of indexes) {
|
||||||
|
if (!index.unique || !index.name || index.name === "_id_") continue;
|
||||||
|
|
||||||
|
const keys = Object.keys(index.key ?? {});
|
||||||
|
const isLegacyPropertyIndex =
|
||||||
|
keys.length === 1 &&
|
||||||
|
(keys[0] === "property" || keys[0] === "property.smsApiKey");
|
||||||
|
|
||||||
|
if (!isLegacyPropertyIndex) continue;
|
||||||
|
|
||||||
|
await this.clientModel.collection.dropIndex(index.name);
|
||||||
|
this.logger.warn(
|
||||||
|
`Dropped legacy unique index on clients.${keys[0]}: ${index.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error("Failed to drop legacy client property index", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(client: ClientModel): Promise<ClientModel> {
|
async create(client: ClientModel): Promise<ClientModel> {
|
||||||
return await this.clientModel.create(client);
|
return await this.clientModel.create(client);
|
||||||
}
|
}
|
||||||
@@ -25,4 +59,14 @@ export class ClientDbService {
|
|||||||
async findAll(): Promise<ClientModel[]> {
|
async findAll(): Promise<ClientModel[]> {
|
||||||
return await this.clientModel.find();
|
return await this.clientModel.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByIdAndUpdate(
|
||||||
|
id: string,
|
||||||
|
update: UpdateQuery<ClientModel>,
|
||||||
|
): Promise<ClientModel | null> {
|
||||||
|
return this.clientModel
|
||||||
|
.findByIdAndUpdate(id, update, { new: true })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export class BranchModel {
|
|||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, default: true })
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Date })
|
||||||
|
activityStartDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BranchSchema = SchemaFactory.createForClass(BranchModel);
|
export const BranchSchema = SchemaFactory.createForClass(BranchModel);
|
||||||
|
|||||||
@@ -1,7 +1,93 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
|
||||||
|
|
||||||
export type ClientDocument = ClientModel & Document;
|
export type ClientDocument = ClientModel & Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-media (video/image/voice) byte bounds. Either bound is optional so a
|
||||||
|
* tenant can configure only one side (e.g. raise `maxBytes` without setting
|
||||||
|
* a floor). When a bound is missing the system-wide default is used (see
|
||||||
|
* {@link ClientService.getMediaLimits} for the defaults table).
|
||||||
|
*/
|
||||||
|
export class ClientMediaLimits {
|
||||||
|
@Prop({ type: Number, required: false })
|
||||||
|
minBytes?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: false })
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bag of per-media-kind limits. Each kind is optional and any unset values
|
||||||
|
* fall back to the system default (see `ClientService.getMediaLimits`).
|
||||||
|
*
|
||||||
|
* Note: the system also keeps an absolute multer ceiling per upload route
|
||||||
|
* (e.g. 50MB for car-capture videos, 20MB for blame videos, 10MB for
|
||||||
|
* voice/signature/image). A client-configured `maxBytes` cannot exceed the
|
||||||
|
* route's multer ceiling because multer rejects oversize requests before
|
||||||
|
* the request reaches the policy check.
|
||||||
|
*/
|
||||||
|
export class ClientMediaSettings {
|
||||||
|
@Prop({ type: ClientMediaLimits, required: false })
|
||||||
|
video?: ClientMediaLimits;
|
||||||
|
|
||||||
|
@Prop({ type: ClientMediaLimits, required: false })
|
||||||
|
image?: ClientMediaLimits;
|
||||||
|
|
||||||
|
@Prop({ type: ClientMediaLimits, required: false })
|
||||||
|
voice?: ClientMediaLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-insurer toggles for outbound inquiry HTTP. Each flag is AND-ed with the
|
||||||
|
* global `system_settings.externalApis.sandHubUseLiveApi` master switch.
|
||||||
|
*/
|
||||||
|
export class ClientExternalInquirySettings implements Partial<ExternalInquiryFlags> {
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
thirdPartyPlate?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
carBodyPlate?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
personalIdentity?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
sheba?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
drivingLicense?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
carOwnership?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tenant tunables. Add new policy fields here; consumers read them via
|
||||||
|
* `ClientService` with documented defaults so older client documents keep
|
||||||
|
* working when a field is missing.
|
||||||
|
*/
|
||||||
|
export class ClientSettings {
|
||||||
|
/**
|
||||||
|
* Max number of days between the accident and the moment a CAR_BODY blame
|
||||||
|
* file is allowed to be submitted. When unset, consumers fall back to the
|
||||||
|
* system default (see {@link ClientService.getCarBodyAccidentMaxAgeDays}).
|
||||||
|
*/
|
||||||
|
@Prop({ type: Number, required: false })
|
||||||
|
carBodyAccidentMaxAgeDays?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-kind media upload bounds (V2 upload endpoints). Unset kinds /
|
||||||
|
* bounds fall back to the defaults in `ClientService.getMediaLimits`.
|
||||||
|
*/
|
||||||
|
@Prop({ type: ClientMediaSettings, required: false })
|
||||||
|
media?: ClientMediaSettings;
|
||||||
|
|
||||||
|
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
|
||||||
|
@Prop({ type: ClientExternalInquirySettings, required: false })
|
||||||
|
externalInquiries?: ClientExternalInquirySettings;
|
||||||
|
}
|
||||||
|
|
||||||
@Schema({ collection: "clients", versionKey: false })
|
@Schema({ collection: "clients", versionKey: false })
|
||||||
export class ClientModel {
|
export class ClientModel {
|
||||||
@Prop({ required: true, unique: true, type: Object })
|
@Prop({ required: true, unique: true, type: Object })
|
||||||
@@ -10,9 +96,9 @@ export class ClientModel {
|
|||||||
english: string;
|
english: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop({ required: false, unique: true, type: Object })
|
@Prop({ required: false, type: Object })
|
||||||
property: {
|
property?: {
|
||||||
smsApiKey: string;
|
smsApiKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop({ required: true, unique: false })
|
@Prop({ required: true, unique: false })
|
||||||
@@ -20,6 +106,9 @@ export class ClientModel {
|
|||||||
|
|
||||||
@Prop({ required: true, unique: false })
|
@Prop({ required: true, unique: false })
|
||||||
clientCode: number;
|
clientCode: number;
|
||||||
|
|
||||||
|
@Prop({ type: ClientSettings, required: false, default: {} })
|
||||||
|
settings?: ClientSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);
|
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);
|
||||||
|
|||||||
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
|
||||||
|
describe("ExternalInquirySettingsService", () => {
|
||||||
|
const systemSettings = {
|
||||||
|
isSandHubLiveEnabled: jest.fn(),
|
||||||
|
};
|
||||||
|
const clientDb = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: ExternalInquirySettingsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env.CLIENT_ID;
|
||||||
|
delete process.env.CLIENT_NAME;
|
||||||
|
service = new ExternalInquirySettingsService(
|
||||||
|
systemSettings as unknown as SystemSettingsService,
|
||||||
|
clientDb as any,
|
||||||
|
);
|
||||||
|
service.invalidateClientCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when global master switch is off", async () => {
|
||||||
|
systemSettings.isSandHubLiveEnabled.mockResolvedValue(false);
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
settings: { externalInquiries: { sheba: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.isInquiryLive("sheba", "client-id")).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true only when global and per-insurer flags are on", async () => {
|
||||||
|
const clientId = new Types.ObjectId().toString();
|
||||||
|
systemSettings.isSandHubLiveEnabled.mockResolvedValue(true);
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
clientName: { persian: "بیمه پارسیان" },
|
||||||
|
settings: {
|
||||||
|
externalInquiries: {
|
||||||
|
thirdPartyPlate: true,
|
||||||
|
sheba: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.isInquiryLive("thirdPartyPlate", clientId),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(service.isInquiryLive("sheba", clientId)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses client document for mock company context", async () => {
|
||||||
|
const clientId = new Types.ObjectId().toString();
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
clientName: { persian: "بیمه پارسیان" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.getMockCompanyContext(clientId)).resolves.toEqual({
|
||||||
|
companyId: "8",
|
||||||
|
companyName: "بیمه پارسیان",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to deployment env when client is missing", async () => {
|
||||||
|
process.env.CLIENT_ID = "15";
|
||||||
|
process.env.CLIENT_NAME = "بیمه سامان";
|
||||||
|
clientDb.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getMockCompanyContext()).resolves.toEqual({
|
||||||
|
companyId: "15",
|
||||||
|
companyName: "بیمه سامان",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/client/external-inquiry-settings.service.ts
Normal file
120
src/client/external-inquiry-settings.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
DEFAULT_EXTERNAL_INQUIRY_FLAGS,
|
||||||
|
ExternalInquiryFlags,
|
||||||
|
ExternalInquiryType,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
MockInquiryCompanyContext,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExternalInquirySettingsService {
|
||||||
|
private readonly logger = new Logger(ExternalInquirySettingsService.name);
|
||||||
|
private clientCache = new Map<string, { doc: any; at: number }>();
|
||||||
|
private readonly cacheTtlMs = 15_000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
private readonly clientDbService: ClientDbService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private cacheKey(ref?: string | Types.ObjectId | null): string {
|
||||||
|
if (ref != null && String(ref).trim()) return `id:${String(ref)}`;
|
||||||
|
const code = process.env.CLIENT_ID;
|
||||||
|
return code ? `env:${code}` : "env:default";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadClient(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<any | null> {
|
||||||
|
const key = this.cacheKey(clientRef);
|
||||||
|
const cached = this.clientCache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now - cached.at < this.cacheTtlMs) {
|
||||||
|
return cached.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc: any = null;
|
||||||
|
if (clientRef != null && Types.ObjectId.isValid(String(clientRef))) {
|
||||||
|
doc = await this.clientDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(String(clientRef)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!doc) {
|
||||||
|
const code = Number(process.env.CLIENT_ID);
|
||||||
|
if (Number.isFinite(code)) {
|
||||||
|
doc = await this.clientDbService.findOne({ clientCode: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientCache.set(key, { doc, at: now });
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateClientCache(clientId?: string): void {
|
||||||
|
if (clientId) {
|
||||||
|
this.clientCache.delete(`id:${clientId}`);
|
||||||
|
} else {
|
||||||
|
this.clientCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlagsForClient(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<ExternalInquiryFlags> {
|
||||||
|
const client = await this.loadClient(clientRef);
|
||||||
|
return mergeExternalInquiryFlags(client?.settings?.externalInquiries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live HTTP when global master switch is on AND the per-insurer flag is true.
|
||||||
|
*/
|
||||||
|
async isInquiryLive(
|
||||||
|
type: ExternalInquiryType,
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!(await this.systemSettingsService.isSandHubLiveEnabled())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const flags = await this.getFlagsForClient(clientRef);
|
||||||
|
return flags[type] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company fields injected into mocked plate/car-body inquiry payloads. */
|
||||||
|
async getMockCompanyContext(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<MockInquiryCompanyContext> {
|
||||||
|
const client = await this.loadClient(clientRef);
|
||||||
|
if (client) {
|
||||||
|
const name =
|
||||||
|
typeof client.clientName === "string"
|
||||||
|
? client.clientName
|
||||||
|
: client.clientName?.persian ||
|
||||||
|
client.clientName?.english ||
|
||||||
|
"";
|
||||||
|
return {
|
||||||
|
companyId: String(client.clientCode ?? process.env.CLIENT_ID ?? "15"),
|
||||||
|
companyName: name || process.env.CLIENT_NAME || "بیمه",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
companyId: String(process.env.CLIENT_ID ?? "15"),
|
||||||
|
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectiveLiveFlags(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<ExternalInquiryFlags> {
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
const flags = await this.getFlagsForClient(clientRef);
|
||||||
|
const effective = { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS };
|
||||||
|
for (const key of Object.keys(effective) as ExternalInquiryType[]) {
|
||||||
|
effective[key] = global && flags[key] === true;
|
||||||
|
}
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/common/auth/constants/hash.constants.ts
Normal file
18
src/common/auth/constants/hash.constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ScryptOptions } from "node:crypto";
|
||||||
|
|
||||||
|
export const CURRENT_ALGORITHM = "scrypt";
|
||||||
|
export const CURRENT_VERSION = 1; // 0 = legacy salt:hash ; 1 = scrypt with different salt and hash and differnet format of salt:hash!
|
||||||
|
|
||||||
|
export const KEY_LENGTH = 64;
|
||||||
|
export const SCRYPT_PARAMS: ScryptOptions = {
|
||||||
|
N: 19456, // CPU/memory cost
|
||||||
|
r: 8, // block size
|
||||||
|
p: 1, // parallelization
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEY_LENGTH_FOR_OTP = 32;
|
||||||
|
export const SCRYPT_PARAMS_FOR_OTP: ScryptOptions = {
|
||||||
|
N: 1024,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
};
|
||||||
8
src/common/auth/constants/index.ts
Normal file
8
src/common/auth/constants/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
CURRENT_ALGORITHM,
|
||||||
|
CURRENT_VERSION,
|
||||||
|
KEY_LENGTH,
|
||||||
|
KEY_LENGTH_FOR_OTP,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
SCRYPT_PARAMS_FOR_OTP,
|
||||||
|
} from "./hash.constants";
|
||||||
2
src/common/auth/decorators/index.ts
Normal file
2
src/common/auth/decorators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { IS_PUBLIC_KEY, Public } from "./public.decorator";
|
||||||
|
export { ROLES_KEY, Roles } from "./roles.decorator";
|
||||||
4
src/common/auth/decorators/public.decorator.ts
Normal file
4
src/common/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = "isPublic";
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/common/auth/decorators/roles.decorator.ts
Normal file
5
src/common/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import { Role } from "../enums/roles.enum";
|
||||||
|
|
||||||
|
export const ROLES_KEY = "roles";
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
1
src/common/auth/enums/index.ts
Normal file
1
src/common/auth/enums/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Role } from "./roles.enum";
|
||||||
9
src/common/auth/enums/roles.enum.ts
Normal file
9
src/common/auth/enums/roles.enum.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum Role {
|
||||||
|
SUPER_ADMIN = "super_admin",
|
||||||
|
INSURER = "insurer",
|
||||||
|
ACCIDENT_EXPERT = "accident_expert",
|
||||||
|
CLAIM_EXPERT = "claim_expert",
|
||||||
|
FIELD_EXPERT = "field_expert",
|
||||||
|
REGISTRAR = "registrar",
|
||||||
|
USER = "user",
|
||||||
|
}
|
||||||
35
src/common/auth/guards/auth.guard.ts
Normal file
35
src/common/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { JwtPayload } from "../types/payload.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException("No token provided");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload: JwtPayload =
|
||||||
|
await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||||
|
request["user"] = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException("Invalid or expired token");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||||
|
return type === "Bearer" ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/common/auth/guards/index.ts
Normal file
2
src/common/auth/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AuthGuard } from "./auth.guard";
|
||||||
|
export { RolesGuard } from "./role.guard";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user