forked from Yara724/api
Compare commits
251 Commits
integrate-
...
eae46c3212
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
72
.env.example
Normal file
72
.env.example
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# ---------------------------------------------
|
||||||
|
# 🔧 Application Environment
|
||||||
|
# ---------------------------------------------
|
||||||
|
NODE_ENV =
|
||||||
|
PORT =
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🌐 Application URLs
|
||||||
|
# ---------------------------------------------
|
||||||
|
URL =
|
||||||
|
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 =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🔐 Authentication / Security
|
||||||
|
# ---------------------------------------------
|
||||||
|
JWT_SECRET =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# 🧩 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 =
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# ⚙️ 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/>
|
|
||||||
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":""}}
|
|
||||||
17242
package-lock.json
generated
17242
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
95
package.json
95
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,
|
||||||
@@ -16,78 +16,61 @@
|
|||||||
"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": [
|
||||||
@@ -105,5 +88,11 @@
|
|||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@nestjs/core",
|
||||||
|
"@swc/core"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
@@ -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,10 +1,10 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Module } from "@nestjs/common";
|
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||||
|
import { Module, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { ConfigModule, 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";
|
||||||
@@ -16,36 +16,86 @@ 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 * as Joi from "joi";
|
||||||
dotenv.config();
|
|
||||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CommandModule,
|
ConfigModule.forRoot({
|
||||||
ScheduleModule.forRoot(),
|
isGlobal: true,
|
||||||
|
validationSchema: Joi.object({
|
||||||
|
NODE_ENV: Joi.string()
|
||||||
|
.valid("development", "production")
|
||||||
|
.default("development"),
|
||||||
|
PORT: Joi.number().port().default(9001),
|
||||||
|
URL: Joi.string().uri().required(),
|
||||||
|
BASE_URL_DEV: Joi.string()
|
||||||
|
.uri()
|
||||||
|
.when("NODE_ENV", {
|
||||||
|
is: Joi.string().valid("development"),
|
||||||
|
then: Joi.required(),
|
||||||
|
otherwise: Joi.optional(),
|
||||||
|
}),
|
||||||
|
SWAGGER_USER_DEV: Joi.string().when("NODE_ENV", {
|
||||||
|
is: Joi.string().valid("development"),
|
||||||
|
then: Joi.required(),
|
||||||
|
otherwise: Joi.optional(),
|
||||||
|
}),
|
||||||
|
SWAGGER_PASSWORD_DEV: Joi.string().when("NODE_ENV", {
|
||||||
|
is: Joi.string().valid("development"),
|
||||||
|
then: Joi.required(),
|
||||||
|
otherwise: Joi.optional(),
|
||||||
|
}),
|
||||||
|
MONGO_HOST: Joi.string().required(),
|
||||||
|
MONGO_PORT: Joi.number().port().required(),
|
||||||
|
MONGO_USER: Joi.string().required(),
|
||||||
|
MONGO_PASS: Joi.string().required(),
|
||||||
|
MONGO_DB_NAME: Joi.string().required(),
|
||||||
|
JWT_SECRET: Joi.string().required(),
|
||||||
|
SANHUB_BASE_URL: Joi.string().uri(),
|
||||||
|
SANHUB_URL_LOGIN: Joi.string().uri(),
|
||||||
|
SANHUB_USERNAME: Joi.string(),
|
||||||
|
SANHUB_PASSWORD: Joi.string(),
|
||||||
|
AI_URL: Joi.string().uri(),
|
||||||
|
AI_URL_V2: Joi.string().uri(),
|
||||||
|
AI_USERNAME: Joi.string(),
|
||||||
|
AI_PASSWORD: Joi.string(),
|
||||||
|
SMS_PROVIDER: Joi.string()
|
||||||
|
.valid("kavenegar", "parsian")
|
||||||
|
.default("kavenegar"),
|
||||||
|
SMS_API_KEY: Joi.string(),
|
||||||
|
AUTH_SMS_TEMPLATE: Joi.string(),
|
||||||
|
EXP_OTP_TIME: Joi.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
CronModule,
|
CronModule,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, "..", "files"),
|
rootPath: join(__dirname, "..", "files"),
|
||||||
serveRoot: "/files",
|
serveRoot: "/files",
|
||||||
}),
|
}),
|
||||||
MongooseModule.forRoot(
|
MongooseModule.forRootAsync({
|
||||||
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
|
inject: [ConfigService],
|
||||||
{
|
useFactory: (configService: ConfigService) => {
|
||||||
dbName: "yara724",
|
return {
|
||||||
autoIndex: true,
|
uri: `mongodb://${configService.get<string>("MONGO_USER")}:${configService.get<string>("MONGO_PASS")}@${configService.get<string>("MONGO_HOST")}:${configService.get<string>("MONGO_PORT")}/${configService.get<string>("MONGO_DB_NAME")}?authSource=admin&${configService.get<string>("MONGO_OPTIONS")}`,
|
||||||
user: process.env.MONGO_USER,
|
tls: configService.get<string>("MONGO_TLS") === "true",
|
||||||
pass: process.env.MONGO_PASS,
|
tlsAllowInvalidCertificates:
|
||||||
authMechanism: "SCRAM-SHA-256",
|
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") ===
|
||||||
tls: true,
|
"true",
|
||||||
tlsAllowInvalidCertificates: true,
|
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
||||||
|
connectionFactory: (connection) => {
|
||||||
|
applyIranFaTimestampPlugin(connection);
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
),
|
}),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
@@ -53,6 +103,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
|||||||
PlatesModule,
|
PlatesModule,
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
SandHubModule,
|
SandHubModule,
|
||||||
|
SystemSettingsModule,
|
||||||
ExpertBlameModule,
|
ExpertBlameModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
ExpertClaimModule,
|
ExpertClaimModule,
|
||||||
@@ -63,6 +114,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,73 @@ 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 the actor's email/`username` and password. 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@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")
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export class UserAuthController {
|
|||||||
})
|
})
|
||||||
@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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -76,8 +78,14 @@ export class ActorAuthService {
|
|||||||
res = await this.fieldExpertDbService.findOne({
|
res = await this.fieldExpertDbService.findOne({
|
||||||
_id: new Types.ObjectId(userId),
|
_id: new Types.ObjectId(userId),
|
||||||
});
|
});
|
||||||
else
|
else res = await this.fieldExpertDbService.findOne({ email: username });
|
||||||
res = await this.fieldExpertDbService.findOne({ email: 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 +96,98 @@ 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;
|
||||||
if (foundedUser) {
|
if (typeof username !== "string" || !username.trim()) {
|
||||||
const payload = {
|
throw new BadRequestException("username (email) is required");
|
||||||
username: foundedUser.username || foundedUser.email,
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
sub: actor._id,
|
||||||
|
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||||
|
role: actor.role || "User",
|
||||||
|
userType: actor.userType || "UserType",
|
||||||
|
clientKey: 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 +222,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 +331,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 +371,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 +442,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 +468,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 +563,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,31 @@
|
|||||||
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,
|
||||||
|
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 +34,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 +68,58 @@ 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 userExist = await this.userDbService.findOne(
|
||||||
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
|
);
|
||||||
const otp = this.otpCreator.create();
|
const otp = this.otpCreator.create();
|
||||||
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
|
|
||||||
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 +133,40 @@ 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);
|
||||||
|
await this.userDbService.findOneAndUpdate(
|
||||||
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
|
{
|
||||||
|
otp: hashOtp,
|
||||||
|
otpExpire,
|
||||||
|
mobile: canonicalMobile,
|
||||||
|
username: userExist.username || canonicalMobile,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new LoginDtoRs(userExist);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async smsSender(otp: string, mobile: string) {
|
private async smsSender(otp: string, mobile: string) {
|
||||||
return this.smsManagerService
|
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||||
.verifyLookUp({
|
mobile,
|
||||||
token: otp,
|
otp,
|
||||||
template: process.env.AUTH_SMS_TEMPLATE,
|
process.env.AUTH_SMS_TEMPLATE,
|
||||||
receptor: mobile,
|
);
|
||||||
})
|
if (!ok) {
|
||||||
.then((smsRes) => {
|
throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
|
||||||
this.logger.log(
|
}
|
||||||
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
|
this.logger.log(
|
||||||
);
|
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||||
})
|
);
|
||||||
.catch((er) => {
|
|
||||||
this.logger.error(
|
|
||||||
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
|
|
||||||
);
|
|
||||||
throw new HttpException(
|
|
||||||
" 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,4 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, 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 {
|
||||||
@@ -10,6 +11,24 @@ export class LoginActorDto {
|
|||||||
|
|
||||||
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,21 +14,24 @@ export class GlobalGuard implements CanActivate {
|
|||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
payload.role !== RoleEnum.USER &&
|
||||||
request.user.role,
|
payload.role !== RoleEnum.FIELD_EXPERT
|
||||||
);
|
) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
request.user = payload;
|
request.user = payload;
|
||||||
request.identity = request.user;
|
request.identity = request.user;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
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: "0oO1ilI",
|
||||||
|
noise: 3,
|
||||||
|
color: true,
|
||||||
|
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,15 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} 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";
|
||||||
@@ -49,6 +50,7 @@ export class ClaimRequestManagementController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@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,
|
||||||
@@ -62,6 +64,7 @@ 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(
|
||||||
@@ -76,6 +79,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(
|
||||||
@@ -90,7 +94,7 @@ export class ClaimRequestManagementController {
|
|||||||
@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 +108,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,16 +124,16 @@ 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) {
|
||||||
@@ -147,7 +152,7 @@ export class ClaimRequestManagementController {
|
|||||||
})
|
})
|
||||||
@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) => {
|
||||||
@@ -171,6 +176,7 @@ export class ClaimRequestManagementController {
|
|||||||
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,
|
||||||
@@ -199,7 +205,7 @@ export class ClaimRequestManagementController {
|
|||||||
})
|
})
|
||||||
@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) => {
|
||||||
@@ -223,6 +229,7 @@ export class ClaimRequestManagementController {
|
|||||||
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,
|
||||||
@@ -249,7 +256,7 @@ export class ClaimRequestManagementController {
|
|||||||
})
|
})
|
||||||
@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) => {
|
||||||
@@ -263,6 +270,7 @@ 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 +282,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",
|
||||||
@@ -318,14 +326,15 @@ export class ClaimRequestManagementController {
|
|||||||
@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")
|
||||||
@@ -343,6 +352,7 @@ 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" })
|
||||||
@@ -372,7 +382,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,6 +403,7 @@ 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 })
|
||||||
@@ -408,6 +419,7 @@ 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" })
|
||||||
@@ -433,7 +445,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(
|
||||||
@@ -452,6 +464,7 @@ 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,6 +479,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
@@ -473,6 +487,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
@@ -481,6 +496,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ 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 { 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 +49,19 @@ 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";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
HttpModule,
|
||||||
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 +93,11 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
ClaimRequiredDocumentDbService,
|
ClaimRequiredDocumentDbService,
|
||||||
ClaimAccessGuard,
|
ClaimAccessGuard,
|
||||||
],
|
],
|
||||||
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
|
controllers: [
|
||||||
|
ClaimRequestManagementController,
|
||||||
|
ClaimRequestManagementV2Controller,
|
||||||
|
RegistrarClaimV1Controller,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ClaimRequestManagementService,
|
ClaimRequestManagementService,
|
||||||
ClaimRequestManagementDbService,
|
ClaimRequestManagementDbService,
|
||||||
@@ -93,6 +105,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)
|
||||||
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 field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||||
})
|
})
|
||||||
@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) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
|
||||||
})
|
})
|
||||||
@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,131 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -112,6 +135,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 +159,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 +178,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,
|
||||||
|
|||||||
136
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
136
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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, ApiBody, ApiConsumes, ApiOperation, ApiParam, 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 { 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";
|
||||||
|
|
||||||
|
@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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,25 +7,27 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
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 { 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 { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
import { ClientDto } from "./dto/create-client.dto";
|
import { ClientDto } from "./dto/create-client.dto";
|
||||||
|
|
||||||
@Controller("client")
|
@Controller("client")
|
||||||
@ApiTags("client-management")
|
@ApiTags("client-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(GlobalGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.ADMIN)
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
constructor(private readonly clientService: ClientService) {}
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
@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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { ClientController } from "./client.controller";
|
import { ClientController } from "./client.controller";
|
||||||
|
import { ClientPanelController } from "./client-panel.controller";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.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";
|
||||||
@@ -17,7 +18,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ClientController],
|
controllers: [ClientController, ClientPanelController],
|
||||||
providers: [ClientService, ClientDbService, BranchDbService],
|
providers: [ClientService, ClientDbService, BranchDbService],
|
||||||
exports: [ClientService, ClientDbService, BranchDbService],
|
exports: [ClientService, ClientDbService, BranchDbService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,97 @@
|
|||||||
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {}
|
||||||
@@ -53,4 +138,212 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,44 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ApiProperty } 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({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
smsApiKey: string;
|
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 +48,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } 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()
|
||||||
@@ -25,4 +25,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);
|
||||||
|
|||||||
@@ -2,6 +2,63 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
|||||||
|
|
||||||
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-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;
|
||||||
|
}
|
||||||
|
|
||||||
@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 })
|
||||||
@@ -20,6 +77,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);
|
||||||
|
|||||||
38
src/common/dto/expert-file-assign-result.dto.ts
Normal file
38
src/common/dto/expert-file-assign-result.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export type ExpertFileAssignStatus =
|
||||||
|
| "assigned"
|
||||||
|
| "already_assigned_to_you"
|
||||||
|
| "locked"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export class ExpertFileAssignResultDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ["assigned", "already_assigned_to_you", "locked", "unavailable"],
|
||||||
|
example: "assigned",
|
||||||
|
})
|
||||||
|
status: ExpertFileAssignStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "You already started processing this request",
|
||||||
|
})
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "When the expert was assigned (ISO 8601).",
|
||||||
|
example: "2026-05-24T12:00:00.000Z",
|
||||||
|
})
|
||||||
|
assignedAt?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Present when status is locked (another expert).",
|
||||||
|
})
|
||||||
|
lockedBy?: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/common/dto/list-query-v2.dto.ts
Normal file
78
src/common/dto/list-query-v2.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/** Allowed sort keys for V2 list endpoints (claims / blame). */
|
||||||
|
export const LIST_SORT_BY_V2 = [
|
||||||
|
"publicId",
|
||||||
|
"createdAt",
|
||||||
|
"requestNo",
|
||||||
|
"status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional query params for V2 lists. If neither `page` nor `limit` is sent,
|
||||||
|
* the full filtered+sorted list is returned (backward compatible).
|
||||||
|
*/
|
||||||
|
export class ListQueryV2Dto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "1-based page index (use together with `limit` for pagination).",
|
||||||
|
example: 1,
|
||||||
|
minimum: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Page size (max 100). If only `page` is set, defaults to 20.",
|
||||||
|
example: 20,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: LIST_SORT_BY_V2,
|
||||||
|
description: "Sort field. Default: `createdAt`.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...LIST_SORT_BY_V2])
|
||||||
|
sortBy?: ListSortByV2;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
description: "Sort direction. Default: `desc` for dates, `asc` for publicId/requestNo.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["asc", "desc"])
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).",
|
||||||
|
example: "A142",
|
||||||
|
maxLength: 64,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes Unicode decimal digits in JSON request bodies to ASCII before
|
||||||
|
* validation and handlers run (Persian/Arabic-Indic → 0-9).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const contentType = String(req.headers?.["content-type"] ?? "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body &&
|
||||||
|
typeof req.body === "object" &&
|
||||||
|
!Buffer.isBuffer(req.body) &&
|
||||||
|
!contentType.includes("multipart/form-data")
|
||||||
|
) {
|
||||||
|
req.body = normalizeUnicodeDigitsDeep(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/common/schema/case-inquiries.schema.ts
Normal file
34
src/common/schema/case-inquiries.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { Schema as MongooseSchema } from "mongoose";
|
||||||
|
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class InquiryStatus {
|
||||||
|
@Prop({ type: Boolean, default: false })
|
||||||
|
has?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: MongooseSchema.Types.Mixed, default: () => ({}) })
|
||||||
|
data?: any;
|
||||||
|
|
||||||
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
|
error?: any;
|
||||||
|
|
||||||
|
@Prop({ type: Date })
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
export const InquiryStatusSchema = SchemaFactory.createForClass(InquiryStatus);
|
||||||
|
|
||||||
|
@Schema({ _id: false, strict: false })
|
||||||
|
export class CaseInquiries {
|
||||||
|
@Prop({ type: InquiryStatusSchema })
|
||||||
|
thirdParty?: InquiryStatus;
|
||||||
|
|
||||||
|
@Prop({ type: InquiryStatusSchema })
|
||||||
|
carBody?: InquiryStatus;
|
||||||
|
|
||||||
|
@Prop({ type: InquiryStatusSchema })
|
||||||
|
person?: InquiryStatus;
|
||||||
|
|
||||||
|
@Prop({ type: InquiryStatusSchema })
|
||||||
|
drivingLicence?: InquiryStatus;
|
||||||
|
}
|
||||||
|
export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries);
|
||||||
24
src/common/validators/has-objection-entries.validator.ts
Normal file
24
src/common/validators/has-objection-entries.validator.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: "hasObjectionEntries", async: false })
|
||||||
|
export class HasObjectionEntriesConstraint
|
||||||
|
implements ValidatorConstraintInterface
|
||||||
|
{
|
||||||
|
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||||
|
const o = args.object as {
|
||||||
|
objectionParts?: unknown[];
|
||||||
|
newParts?: unknown[];
|
||||||
|
};
|
||||||
|
const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0;
|
||||||
|
const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0;
|
||||||
|
return partsLen > 0 || newLen > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
return "Provide at least one entry in objectionParts and/or newParts.";
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/common/validators/money-amount-string.validator.ts
Normal file
37
src/common/validators/money-amount-string.validator.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from "class-validator";
|
||||||
|
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
||||||
|
export class IsMoneyAmountStringConstraint
|
||||||
|
implements ValidatorConstraintInterface
|
||||||
|
{
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
if (value == null || value === "") return true;
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
const n = normalizeMoneyAmountString(value);
|
||||||
|
if (!n) return false;
|
||||||
|
return /^\d+(\.\d+)?$/.test(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
return "Must be a non-negative amount (digits only, optional decimal).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsMoneyAmountString(validationOptions?: ValidationOptions) {
|
||||||
|
return (object: object, propertyName: string) => {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsMoneyAmountStringConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/common/validators/objection-part-content.validator.ts
Normal file
29
src/common/validators/objection-part-content.validator.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: "objectionPartHasContent", async: false })
|
||||||
|
export class ObjectionPartHasContentConstraint
|
||||||
|
implements ValidatorConstraintInterface
|
||||||
|
{
|
||||||
|
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||||
|
const o = args.object as {
|
||||||
|
reason?: string;
|
||||||
|
partPrice?: string;
|
||||||
|
partSalary?: string;
|
||||||
|
};
|
||||||
|
const hasReason =
|
||||||
|
typeof o.reason === "string" && o.reason.trim().length >= 3;
|
||||||
|
const hasPrice =
|
||||||
|
o.partPrice != null && String(o.partPrice).trim() !== "";
|
||||||
|
const hasSalary =
|
||||||
|
o.partSalary != null && String(o.partSalary).trim() !== "";
|
||||||
|
return hasReason || hasPrice || hasSalary;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary.";
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from "class-validator";
|
||||||
|
import {
|
||||||
|
REPAIR_LINE_AMOUNT_TOMAN,
|
||||||
|
} from "src/constants/repair-amount-limits";
|
||||||
|
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
|
||||||
|
|
||||||
|
export type RepairLineAmountTomanOptions = {
|
||||||
|
/** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */
|
||||||
|
allowZero?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false })
|
||||||
|
export class IsRepairLineAmountTomanConstraint
|
||||||
|
implements ValidatorConstraintInterface
|
||||||
|
{
|
||||||
|
validate(value: unknown, args: ValidationArguments): boolean {
|
||||||
|
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
boolean | undefined,
|
||||||
|
];
|
||||||
|
if (value == null || value === "") return true;
|
||||||
|
const amount = parseMoneyAmountToman(value);
|
||||||
|
if (amount == null) return false;
|
||||||
|
if (allowZero && amount === 0) return true;
|
||||||
|
return amount >= minToman && amount <= maxToman;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
boolean | undefined,
|
||||||
|
];
|
||||||
|
const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : "";
|
||||||
|
return (
|
||||||
|
`Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional repair-line amount in Toman (digits only after unicode normalize). */
|
||||||
|
export function IsRepairLineAmountToman(
|
||||||
|
options?: ValidationOptions & RepairLineAmountTomanOptions,
|
||||||
|
) {
|
||||||
|
const { allowZero, ...validationOptions } = options ?? {};
|
||||||
|
return (object: object, propertyName: string) => {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [
|
||||||
|
REPAIR_LINE_AMOUNT_TOMAN.MIN,
|
||||||
|
REPAIR_LINE_AMOUNT_TOMAN.MAX,
|
||||||
|
allowZero === true,
|
||||||
|
],
|
||||||
|
validator: IsRepairLineAmountTomanConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/constants/repair-amount-limits.ts
Normal file
12
src/constants/repair-amount-limits.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
||||||
|
*/
|
||||||
|
export const REPAIR_LINE_AMOUNT_TOMAN = {
|
||||||
|
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
||||||
|
MIN: 10_000,
|
||||||
|
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
||||||
|
MAX: 53_000_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||||
|
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
|
||||||
|
|
||||||
export const CustomHeader = createParamDecorator(
|
|
||||||
(data, ctx: ExecutionContext) => {
|
|
||||||
console.log(ctx.switchToHttp().getRequest());
|
|
||||||
console.log(ctx.getType());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||||
|
|
||||||
export class AllRequestDto {
|
export class AllRequestDto {
|
||||||
@@ -117,11 +118,35 @@ export class AllRequestDtoV2 {
|
|||||||
type: string;
|
type: string;
|
||||||
blameStatus: string;
|
blameStatus: string;
|
||||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||||
|
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AllRequestDtoRsV2 {
|
export class AllRequestDtoRsV2 {
|
||||||
data: AllRequestDtoV2[];
|
data: AllRequestDtoV2[];
|
||||||
constructor(items: AllRequestDtoV2[]) {
|
|
||||||
this.data = items;
|
@ApiProperty({ description: "Total rows after search filter" })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
totalPages?: number;
|
||||||
|
|
||||||
|
constructor(payload: {
|
||||||
|
data: AllRequestDtoV2[];
|
||||||
|
total: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
}) {
|
||||||
|
this.data = payload.data;
|
||||||
|
this.total = payload.total;
|
||||||
|
this.page = payload.page;
|
||||||
|
this.limit = payload.limit;
|
||||||
|
this.totalPages = payload.totalPages;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ export class ResendRequestDto {
|
|||||||
description: "Array of resend requests (can include first party, second party, or both)",
|
description: "Array of resend requests (can include first party, second party, or both)",
|
||||||
example: [
|
example: [
|
||||||
{
|
{
|
||||||
partyId: "507f1f77bcf86cd799439011",
|
partyId: "681b3b3fb237e5856429e444",
|
||||||
requestedItems: ["drivingLicense", "carCertificate"],
|
requestedItems: ["drivingLicense"],
|
||||||
description: "Please resend with better quality",
|
description: "DESC TEST1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyId: "68b6ba552a6e897df34e0f90",
|
||||||
|
requestedItems: ["carCertificate"],
|
||||||
|
description: "DESC TEST2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiProduces,
|
ApiProduces,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response, Request } from "express";
|
import { Response, Request } from "express";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
@@ -25,7 +26,6 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
|||||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
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";
|
||||||
import { LoggingInterceptor } from "src/interceptor/logging.interceptors";
|
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import {
|
import {
|
||||||
ResendFirstPartyDto,
|
ResendFirstPartyDto,
|
||||||
@@ -45,30 +45,35 @@ export class ExpertBlameController {
|
|||||||
|
|
||||||
// TODO role guard for expert fix
|
// TODO role guard for expert fix
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||||
return await this.expertBlameService.findAll(actor);
|
return await this.expertBlameService.findAll(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.findOne(id, actor.sub);
|
return await this.expertBlameService.findOne(id, actor.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("lock/:id")
|
@Put("lock/:id")
|
||||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.lockRequest(id, actor);
|
return await this.expertBlameService.lockRequest(id, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("request/accident-fields")
|
@Get("request/accident-fields")
|
||||||
async getAccidentFields() {
|
async getAccidentFields() {
|
||||||
return await this.expertBlameService.getAccidentField();
|
return await this.expertBlameService.getAccidentField();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/submit/:id")
|
@Put("reply/submit/:id")
|
||||||
@ApiBody({ type: SubmitReplyDto })
|
@ApiBody({ type: SubmitReplyDto })
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
@@ -95,6 +100,7 @@ export class ExpertBlameController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/resend/first/:id")
|
@Put("reply/resend/first/:id")
|
||||||
@ApiBody({ type: ResendFirstPartyDto })
|
@ApiBody({ type: ResendFirstPartyDto })
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
@@ -107,6 +113,7 @@ export class ExpertBlameController {
|
|||||||
return this.handleResendRequest(id, body, actor.sub, req);
|
return this.handleResendRequest(id, body, actor.sub, req);
|
||||||
}
|
}
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/resend/second/:id")
|
@Put("reply/resend/second/:id")
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
@ApiBody({ type: ResendSecondPartyDto })
|
@ApiBody({ type: ResendSecondPartyDto })
|
||||||
@@ -120,13 +127,14 @@ export class ExpertBlameController {
|
|||||||
return this.handleResendRequest(id, body, actor.sub, req);
|
return this.handleResendRequest(id, body, actor.sub, req);
|
||||||
}
|
}
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("stream/:requestId")
|
@Get("stream/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
async streamVideo(@Param("requestId") requestId: string) {
|
async streamVideo(@Param("requestId") requestId: string) {
|
||||||
return this.expertBlameService.streamVideo(requestId);
|
return this.expertBlameService.streamVideo(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseInterceptors(LoggingInterceptor)
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("voice/:requestId/:voiceId")
|
@Get("voice/:requestId/:voiceId")
|
||||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@@ -149,6 +157,7 @@ export class ExpertBlameController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ import { ExpertBlameController } from "./expert-blame.controller";
|
|||||||
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
||||||
import { ExpertBlameService } from "./expert-blame.service";
|
import { ExpertBlameService } from "./expert-blame.service";
|
||||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||||
|
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule],
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
ClientModule,
|
||||||
|
RequestManagementModule,
|
||||||
|
PlatesModule,
|
||||||
|
SmsOrchestrationModule,
|
||||||
|
],
|
||||||
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
||||||
providers: [ExpertBlameService],
|
providers: [ExpertBlameService],
|
||||||
exports: [ExpertBlameService],
|
exports: [ExpertBlameService],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,21 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.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 { ExpertBlameService } from "./expert-blame.service";
|
import { ExpertBlameService } from "./expert-blame.service";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
import { AllRequestDtoRsV2 } from "./dto/all-request.dto";
|
||||||
import { SubmitReplyDto } from "./dto/reply.dto";
|
import { SubmitReplyDto } from "./dto/reply.dto";
|
||||||
import { ResendRequestDto } from "./dto/resend.dto";
|
import { ResendRequestDto } from "./dto/resend.dto";
|
||||||
|
|
||||||
@@ -22,14 +27,22 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
|||||||
@Controller("v2/expert-blame")
|
@Controller("v2/expert-blame")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||||
export class ExpertBlameV2Controller {
|
export class ExpertBlameV2Controller {
|
||||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
constructor(private readonly expertBlameService: ExpertBlameService) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() actor: any) {
|
@ApiOperation({
|
||||||
|
summary: "List blame cases for field expert (V2)",
|
||||||
|
description:
|
||||||
|
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||||
|
})
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() actor: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
): Promise<AllRequestDtoRsV2> {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.findAllV2(actor);
|
return await this.expertBlameService.findAllV2(actor, query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -38,11 +51,28 @@ export class ExpertBlameV2Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("report/status-counts")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||||
|
description:
|
||||||
|
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.",
|
||||||
|
})
|
||||||
|
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||||
|
try {
|
||||||
|
return await this.expertBlameService.getStatusReportBucketsV2(actor);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to load blame status report",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.findOneV2(id, actor.sub);
|
return await this.expertBlameService.findOneV2(id, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -51,7 +81,36 @@ export class ExpertBlameV2Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/assign")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Check availability and assign blame case to this expert",
|
||||||
|
description:
|
||||||
|
"Call before opening a case from the list. Returns `assigned` when the case was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Does not replace PUT lock — use this for list UI availability.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
|
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: "Another expert is processing this case",
|
||||||
|
type: ExpertFileAssignResultDto,
|
||||||
|
})
|
||||||
|
async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
|
try {
|
||||||
|
return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to assign blame case",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Put("lock/:id")
|
@Put("lock/:id")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Lock blame case for review (legacy)",
|
||||||
|
description:
|
||||||
|
"Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.",
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
|
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +132,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.resendRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -93,7 +152,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.replyRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -101,4 +160,22 @@ export class ExpertBlameV2Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put("reply/inPerson/:id")
|
||||||
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
|
@ApiBody({ type: SubmitReplyDto })
|
||||||
|
async submitInPerson(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() body: SubmitReplyDto,
|
||||||
|
@CurrentUser() actor: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.expertBlameService.submitInPerson(id, actor.sub);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to submit expert reply for in person",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/expert-claim/dto/claim-damaged-part.enricher.ts
Normal file
138
src/expert-claim/dto/claim-damaged-part.enricher.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// claim-damaged-part.enricher.ts
|
||||||
|
|
||||||
|
import {
|
||||||
|
DamagedPartCaptureStatus,
|
||||||
|
EnrichedDamagedPart,
|
||||||
|
} from "./claim-damaged-part.types";
|
||||||
|
|
||||||
|
export function buildEnrichedDamagedParts(params: {
|
||||||
|
selectedParts: any[];
|
||||||
|
damagedPartsData: any;
|
||||||
|
evaluationBlock: any;
|
||||||
|
expertAddedParts?: any[];
|
||||||
|
getDamagedPartCaptureBlob: (data: any, key: string, parts: any[]) => any;
|
||||||
|
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
|
||||||
|
catalogLikeKeyFromPart: (part: any) => string;
|
||||||
|
buildFileLink: (path: string) => string;
|
||||||
|
}): EnrichedDamagedPart[] {
|
||||||
|
const {
|
||||||
|
selectedParts,
|
||||||
|
damagedPartsData,
|
||||||
|
evaluationBlock,
|
||||||
|
expertAddedParts = [],
|
||||||
|
getDamagedPartCaptureBlob,
|
||||||
|
hasDamagedPartCapture,
|
||||||
|
catalogLikeKeyFromPart,
|
||||||
|
buildFileLink,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
|
||||||
|
const objectionParts: any[] =
|
||||||
|
evaluationBlock?.objection?.objectionParts ?? [];
|
||||||
|
const newObjectionParts: any[] = evaluationBlock?.objection?.newParts ?? [];
|
||||||
|
|
||||||
|
// Resend block — lives at evaluation.damageExpertResend
|
||||||
|
const damageExpertResend = evaluationBlock?.damageExpertResend;
|
||||||
|
const resendCarParts: any[] = damageExpertResend?.resendCarParts ?? [];
|
||||||
|
const resendFulfilledAt: Date | undefined = damageExpertResend?.fulfilledAt;
|
||||||
|
|
||||||
|
function isPartInResend(sp: any): boolean {
|
||||||
|
return resendCarParts.some(
|
||||||
|
(r: any) =>
|
||||||
|
r.id === sp.id || r.name === sp.name || r.catalogKey === sp.catalogKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrigin(sp: any): EnrichedDamagedPart["origin"] {
|
||||||
|
if (
|
||||||
|
newObjectionParts.some((p: any) => p.id === sp.id || p.name === sp.name)
|
||||||
|
) {
|
||||||
|
return "added_by_objection";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expertAddedParts.some((p: any) => p.id === sp.id || p.name === sp.name)
|
||||||
|
) {
|
||||||
|
return "added_by_expert";
|
||||||
|
}
|
||||||
|
return "user_selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCaptureStatus(
|
||||||
|
sp: any,
|
||||||
|
captured: boolean,
|
||||||
|
): DamagedPartCaptureStatus {
|
||||||
|
const inResend = isPartInResend(sp);
|
||||||
|
if (!captured) {
|
||||||
|
return inResend ? "resend_requested" : "pending";
|
||||||
|
}
|
||||||
|
// captured=true + was in a resend request that is now fulfilled → "resent"
|
||||||
|
return inResend && resendFulfilledAt ? "resent" : "uploaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedParts.map((sp, index) => {
|
||||||
|
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||||
|
|
||||||
|
const cap = getDamagedPartCaptureBlob(
|
||||||
|
damagedPartsData,
|
||||||
|
ck,
|
||||||
|
selectedParts,
|
||||||
|
) as { url?: string; path?: string } | undefined;
|
||||||
|
const captured = hasDamagedPartCapture(damagedPartsData, ck, selectedParts);
|
||||||
|
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
|
||||||
|
|
||||||
|
const matchingPriceDrop = priceDropLines.find(
|
||||||
|
(line: any) =>
|
||||||
|
line.partId === sp.id ||
|
||||||
|
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isObjected = objectionParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const isNewlyAdded = newObjectionParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const isNewByExpert = expertAddedParts.some(
|
||||||
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
|
);
|
||||||
|
const inResend = isPartInResend(sp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
partId: sp.id ?? null,
|
||||||
|
id: sp.id ?? null,
|
||||||
|
name: sp.name,
|
||||||
|
side: sp.side,
|
||||||
|
label_fa: sp.label_fa,
|
||||||
|
...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}),
|
||||||
|
|
||||||
|
origin: resolveOrigin(sp),
|
||||||
|
captureStatus: resolveCaptureStatus(sp, captured),
|
||||||
|
captured,
|
||||||
|
url,
|
||||||
|
|
||||||
|
objection: {
|
||||||
|
isObjected,
|
||||||
|
isNewlyAdded,
|
||||||
|
...(isNewlyAdded ? { addedBy: isNewByExpert ? "expert" : "user" } : {}),
|
||||||
|
},
|
||||||
|
|
||||||
|
resend: {
|
||||||
|
wasRequested: inResend,
|
||||||
|
isFulfilled: inResend && !!resendFulfilledAt,
|
||||||
|
...(inResend && damageExpertResend?.requestedAt
|
||||||
|
? { requestedAt: damageExpertResend.requestedAt }
|
||||||
|
: {}),
|
||||||
|
...(inResend && resendFulfilledAt
|
||||||
|
? { fulfilledAt: resendFulfilledAt }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
|
||||||
|
pricing: {
|
||||||
|
hasPriceDrop: !!matchingPriceDrop,
|
||||||
|
severity: matchingPriceDrop?.severity ?? null,
|
||||||
|
coefficient: matchingPriceDrop?.coefficient ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
40
src/expert-claim/dto/claim-damaged-part.types.ts
Normal file
40
src/expert-claim/dto/claim-damaged-part.types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// claim-damaged-part.types.ts — update the type
|
||||||
|
export type DamagedPartCaptureStatus =
|
||||||
|
| "pending" // never uploaded
|
||||||
|
| "uploaded" // uploaded, no issues
|
||||||
|
| "resend_requested" // expert asked to redo this part's photo
|
||||||
|
| "resent"; // owner re-uploaded after resend request
|
||||||
|
|
||||||
|
export interface EnrichedDamagedPart {
|
||||||
|
index: number;
|
||||||
|
partId: number | null;
|
||||||
|
id: number | null;
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
label_fa: string;
|
||||||
|
catalogKey?: string;
|
||||||
|
|
||||||
|
origin: "user_selected" | "added_by_objection" | "added_by_expert";
|
||||||
|
captureStatus: DamagedPartCaptureStatus;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
objection: {
|
||||||
|
isObjected: boolean;
|
||||||
|
isNewlyAdded: boolean;
|
||||||
|
addedBy?: "user" | "expert";
|
||||||
|
};
|
||||||
|
|
||||||
|
resend: {
|
||||||
|
wasRequested: boolean; // expert included this part in a resend request
|
||||||
|
isFulfilled: boolean; // owner completed the resend
|
||||||
|
requestedAt?: Date;
|
||||||
|
fulfilledAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
pricing: {
|
||||||
|
hasPriceDrop: boolean;
|
||||||
|
severity: string | null;
|
||||||
|
coefficient: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
|
||||||
|
|
||||||
export class ClaimDetailV2ResponseDto {
|
export class ClaimDetailV2ResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -19,7 +21,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
nextStep?: string;
|
nextStep?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Whether claim is locked' })
|
@ApiProperty({ description: "Whether claim is locked" })
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -27,6 +29,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
lockedAt: string;
|
lockedAt: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -43,31 +46,172 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
plate?: any;
|
plate?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
|
@ApiPropertyOptional({
|
||||||
|
description: "Blame file type from linked blame case",
|
||||||
|
enum: BlameRequestType,
|
||||||
|
})
|
||||||
|
blameRequestType?: BlameRequestType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
|
||||||
|
example: { car: true, object: false },
|
||||||
|
})
|
||||||
|
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Blame request ID", example: "507f..." })
|
||||||
blameRequestId?: string;
|
blameRequestId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
|
@ApiPropertyOptional({
|
||||||
|
description: "Blame request number",
|
||||||
|
example: "BL12345",
|
||||||
|
})
|
||||||
blameRequestNo?: string;
|
blameRequestNo?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
@ApiPropertyOptional({
|
||||||
selectedParts?: string[];
|
description: "Selected outer damaged parts (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[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
@ApiPropertyOptional({ description: "Required documents status" })
|
||||||
requiredDocuments?: Record<string, {
|
requiredDocuments?: Record<
|
||||||
uploaded: boolean;
|
string,
|
||||||
fileId?: string;
|
{
|
||||||
fileName?: string;
|
uploaded: boolean;
|
||||||
filePath?: string;
|
fileId?: string;
|
||||||
}>;
|
fileName?: string;
|
||||||
|
filePath?: 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 captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)",
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
index: { type: "number" },
|
||||||
|
partId: {
|
||||||
|
type: "number",
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop",
|
||||||
|
},
|
||||||
|
id: { type: "number", nullable: true },
|
||||||
|
name: { type: "string" },
|
||||||
|
side: { type: "string" },
|
||||||
|
label_fa: { type: "string" },
|
||||||
|
captured: { type: "boolean" },
|
||||||
|
url: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
damagedParts?: Array<{
|
||||||
|
index: number;
|
||||||
|
partId: number | null;
|
||||||
|
id?: number | null;
|
||||||
|
name: string;
|
||||||
|
side: string;
|
||||||
|
label_fa: string;
|
||||||
|
captured: boolean;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"True when user uploaded all required factors and the case awaits expert approve/reject.",
|
||||||
|
})
|
||||||
|
awaitingFactorValidation?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
||||||
|
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
|
||||||
|
"awaiting factor validation. `ownerInsurerApproval` and " +
|
||||||
|
"`ownerPricedPartsApproval` are returned whenever they exist on the " +
|
||||||
|
"claim — each carries `signLink` (resolved from `signDetailId`) so the " +
|
||||||
|
"front-end can render the user signature directly. Likewise, " +
|
||||||
|
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
|
||||||
|
"expose `signLink` when a user comment signature is present.",
|
||||||
|
})
|
||||||
|
evaluation?: {
|
||||||
|
damageExpertReply?: unknown;
|
||||||
|
damageExpertReplyFinal?: unknown;
|
||||||
|
damageExpertResend: unknown;
|
||||||
|
ownerInsurerApproval?: {
|
||||||
|
agree: boolean;
|
||||||
|
branchId?: string;
|
||||||
|
signDetailId?: string;
|
||||||
|
signLink?: string;
|
||||||
|
signedAt?: Date | string;
|
||||||
|
};
|
||||||
|
ownerPricedPartsApproval?: {
|
||||||
|
agree: boolean;
|
||||||
|
branchId?: string;
|
||||||
|
signDetailId?: string;
|
||||||
|
signLink?: string;
|
||||||
|
signedAt?: Date | string;
|
||||||
|
};
|
||||||
|
priceDrop?: {
|
||||||
|
total?: number;
|
||||||
|
carPrice?: number;
|
||||||
|
carModel?: number;
|
||||||
|
carValue?: number[];
|
||||||
|
sumOfSeverity?: number;
|
||||||
|
coefficientYear?: number;
|
||||||
|
partLines?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.",
|
||||||
|
})
|
||||||
|
objection?: {
|
||||||
|
objectionParts?: Array<Record<string, unknown>>;
|
||||||
|
newParts?: Array<Record<string, unknown>>;
|
||||||
|
submittedAt?: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).",
|
||||||
|
})
|
||||||
|
videoCapture?: {
|
||||||
|
id: string;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
|
||||||
|
})
|
||||||
|
blameCase?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.",
|
||||||
|
})
|
||||||
|
blameExpertDecision?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Claim payout / banking metadata from the claim file (e.g. Sheba).",
|
||||||
|
})
|
||||||
|
money?: {
|
||||||
|
sheba?: string;
|
||||||
|
nationalCodeOfInsurer?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||||
|
|
||||||
export class ClaimListItemV2Dto {
|
export class ClaimListItemV2Dto {
|
||||||
@ApiProperty({ description: 'Claim case ID' })
|
@ApiProperty({ description: 'Claim case ID' })
|
||||||
@@ -7,7 +8,11 @@ export class ClaimListItemV2Dto {
|
|||||||
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
|
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
|
||||||
publicId: string;
|
publicId: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"ClaimCaseStatus (expert list may normalize EXPERT_REVIEWING → WAITING_FOR_DAMAGE_EXPERT). Post-expert owner phase: INSURER_REVIEW_* / OWNER_REPAIR_FACTOR_UPLOAD_PENDING / EXPERT_VALIDATING_REPAIR_FACTORS; see expert reply & validate-factors docs.",
|
||||||
|
example: "WAITING_FOR_DAMAGE_EXPERT",
|
||||||
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
||||||
@@ -16,10 +21,12 @@ export class ClaimListItemV2Dto {
|
|||||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||||
lockedBy?: {
|
lockedBy?: {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||||
@@ -29,14 +36,43 @@ export class ClaimListItemV2Dto {
|
|||||||
carType?: string;
|
carType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Blame file type from linked blame case',
|
||||||
|
enum: BlameRequestType,
|
||||||
|
example: BlameRequestType.THIRD_PARTY,
|
||||||
|
})
|
||||||
|
blameRequestType?: BlameRequestType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
|
||||||
|
example: { car: true, object: false },
|
||||||
|
})
|
||||||
|
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||||
|
|
||||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
|
||||||
|
})
|
||||||
|
awaitingFactorValidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetClaimListV2ResponseDto {
|
export class GetClaimListV2ResponseDto {
|
||||||
@ApiProperty({ type: [ClaimListItemV2Dto] })
|
@ApiProperty({ type: [ClaimListItemV2Dto] })
|
||||||
list: ClaimListItemV2Dto[];
|
list: ClaimListItemV2Dto[];
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total count' })
|
@ApiProperty({ description: 'Total count after search filter' })
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Current page when paginating' })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Page size when paginating' })
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total pages when paginating' })
|
||||||
|
totalPages?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import type { PriceDropSeverity } from "src/helpers/claim-price-drop";
|
||||||
|
|
||||||
|
export class PriceDropPartSeverityV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 12,
|
||||||
|
description: "From claim detail `damagedParts[].partId`",
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
partId: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ["Minor", "Moderate", "Severe"],
|
||||||
|
description: "Severity: جزئی / متوسط / شدید",
|
||||||
|
})
|
||||||
|
@IsIn(["Minor", "Moderate", "Severe"])
|
||||||
|
severity: PriceDropSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertClaimPriceDropV2Dto {
|
||||||
|
@ApiPropertyOptional({ example: "پژو 206" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "پژو 206 تیپ 5",
|
||||||
|
description: "Display model string stored on `claim.vehicle.carModel`",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carModel?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 450000000,
|
||||||
|
description: "Market price of the car (Toman)",
|
||||||
|
})
|
||||||
|
carPrice: number | string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 1394,
|
||||||
|
description:
|
||||||
|
"Jalali production year (coefficient). Defaults from linked blame plate inquiry when omitted.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
carModelYear?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [PriceDropPartSeverityV2Dto],
|
||||||
|
description:
|
||||||
|
"One row per damaged part on the claim; coefficients come from the price-drop table × severity.",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PriceDropPartSeverityV2Dto)
|
||||||
|
partSeverities: PriceDropPartSeverityV2Dto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaimPriceDropContextV2Dto {
|
||||||
|
@ApiProperty()
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
vehicle?: {
|
||||||
|
carName?: string;
|
||||||
|
carModel?: string;
|
||||||
|
carType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Jalali year from blame plate inquiry (ModelField / ModelCii)",
|
||||||
|
})
|
||||||
|
suggestedCarModelYear?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
priceDrop?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { type: "string" },
|
||||||
|
label_fa: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
severityOptions: Array<{ value: string; label_fa: string }>;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Full coefficient table for all price-drop parts" })
|
||||||
|
priceDropCatalog: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Claim damaged parts with optional price-drop key mapping",
|
||||||
|
})
|
||||||
|
damagedParts: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaimPriceDropResultV2Dto {
|
||||||
|
@ApiProperty()
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
vehicle: {
|
||||||
|
carName?: string;
|
||||||
|
carModel?: string;
|
||||||
|
carType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
priceDrop: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiProperty({ type: "array" })
|
||||||
|
partLines: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user