forked from Yara724/api
Compare commits
17 Commits
0111f3acd2
...
d355771518
| Author | SHA1 | Date | |
|---|---|---|---|
| d355771518 | |||
| f4301428c7 | |||
| e3406f7645 | |||
| 4d6183fa24 | |||
| ce4945ebb8 | |||
| 5cada3c6c8 | |||
| 761f0cb679 | |||
| 3c61e4397a | |||
| fae7e9b13b | |||
| 3c863bb90c | |||
| 0c5756d325 | |||
| 1670c9d145 | |||
| 92e05d2a49 | |||
| dcc3ee71de | |||
| fa188862e5 | |||
| d8f7766f10 | |||
| 084d0e1360 |
191
package-lock.json
generated
191
package-lock.json
generated
@@ -179,24 +179,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -204,22 +186,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.18.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
|
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
@@ -275,24 +241,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -300,22 +248,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.18.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
@@ -1075,9 +1007,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@hapi/tlds": {
|
"node_modules/@hapi/tlds": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz",
|
||||||
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
|
"integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -2175,6 +2107,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2192,6 +2127,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2209,6 +2147,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2226,6 +2167,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2243,6 +2187,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2905,24 +2852,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schematics/node_modules/chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
|
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -2930,22 +2859,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/schematics/node_modules/readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.18.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
@@ -3340,6 +3253,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3357,6 +3273,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3374,6 +3293,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3391,6 +3313,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5794,24 +5719,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chrome-trace-event": {
|
"node_modules/chrome-trace-event": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||||
@@ -7254,16 +7161,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -7711,9 +7618,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -8810,9 +8717,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/joi": {
|
"node_modules/joi": {
|
||||||
"version": "18.2.1",
|
"version": "18.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz",
|
||||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
"integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hapi/address": "^5.1.1",
|
"@hapi/address": "^5.1.1",
|
||||||
@@ -10396,22 +10303,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
|||||||
@@ -84,6 +84,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import { AppConfigModule } from "./core/config/config.module";
|
|||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
CronModule,
|
CronModule,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, "..", "files"),
|
// process.cwd() is always the project/container root (/app in Docker),
|
||||||
|
// so the volume-mounted /app/files is resolved correctly regardless of
|
||||||
|
// where the compiled dist files live (__dirname would resolve to
|
||||||
|
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
|
||||||
|
rootPath: join(process.cwd(), "files"),
|
||||||
serveRoot: "/files",
|
serveRoot: "/files",
|
||||||
}),
|
}),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
|
const GLOBAL_GUARD_ROLES = new Set<string>([
|
||||||
|
RoleEnum.USER,
|
||||||
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.REGISTRAR,
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GlobalGuard implements CanActivate {
|
export class GlobalGuard implements CanActivate {
|
||||||
constructor(private readonly jwtService: JwtService) {}
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
@@ -17,7 +23,7 @@ export class GlobalGuard implements CanActivate {
|
|||||||
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException("Missing Bearer token");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -25,17 +31,19 @@ export class GlobalGuard implements CanActivate {
|
|||||||
secret: `${process.env.JWT_SECRET}`,
|
secret: `${process.env.JWT_SECRET}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) {
|
||||||
payload.role !== RoleEnum.USER &&
|
throw new UnauthorizedException(
|
||||||
payload.role !== RoleEnum.FIELD_EXPERT
|
`Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`,
|
||||||
) {
|
);
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.user = payload;
|
request.user = payload;
|
||||||
request.identity = request.user;
|
request.identity = request.user;
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new UnauthorizedException();
|
if (error instanceof UnauthorizedException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException("Invalid or expired token");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { Reflector } from "@nestjs/core";
|
|||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
constructor(private readonly reflector: Reflector) {}
|
constructor(private readonly reflector: Reflector) {}
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
// get the roles required
|
|
||||||
const roles = this.reflector.getAllAndOverride<string[]>("role", [
|
const roles = this.reflector.getAllAndOverride<string[]>("role", [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
if (!roles) {
|
if (!roles?.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const userRoles = request.user?.role?.split(",");
|
const role = request.user?.role;
|
||||||
|
if (!role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const userRoles = String(role).split(",");
|
||||||
return this.validateRoles(roles, userRoles);
|
return this.validateRoles(roles, userRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ import {
|
|||||||
} from "./dto/my-claims-v2.dto";
|
} from "./dto/my-claims-v2.dto";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||||
|
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
||||||
|
import {
|
||||||
|
buildBlamePartyAccessOrConditions,
|
||||||
|
collectUserIdVariants,
|
||||||
|
} from "src/helpers/party-access-queries";
|
||||||
|
import {
|
||||||
|
buildOwnerUserIdAccessFilter,
|
||||||
|
ownerUserIdMatchesActor,
|
||||||
|
resolveLinkedUserIdStrings,
|
||||||
|
} from "src/helpers/user-access-resolver";
|
||||||
|
import {
|
||||||
|
blameDamagedPartyMatchesUser,
|
||||||
|
resolveClaimOwnerParty,
|
||||||
|
resolveDamagedPartyUserId,
|
||||||
|
} from "src/helpers/blame-damaged-party";
|
||||||
|
import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope";
|
||||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||||
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 { 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";
|
||||||
@@ -2713,14 +2729,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claim.owner?.userId ||
|
claim,
|
||||||
claim.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"You do not have access to this claim file",
|
"You do not have access to this claim file",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
||||||
? "damageExpertReplyFinal"
|
? "damageExpertReplyFinal"
|
||||||
@@ -4574,6 +4588,19 @@ export class ClaimRequestManagementService {
|
|||||||
const claimNo = await this.generateUniqueClaimNumber();
|
const claimNo = await this.generateUniqueClaimNumber();
|
||||||
|
|
||||||
// 8. Create claim case
|
// 8. Create claim case
|
||||||
|
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
||||||
|
if (!ownerParty?.person?.userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Could not resolve claim owner from blame parties",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const damagedUserId = (() => {
|
||||||
|
const oid = String(ownerParty.person.userId);
|
||||||
|
const dp = (blameRequest.parties || []).find(
|
||||||
|
(p: any) => p.person?.userId && String(p.person.userId) !== oid,
|
||||||
|
);
|
||||||
|
return dp?.person?.userId ? String(dp.person.userId) : null;
|
||||||
|
})();
|
||||||
const newClaim = await this.claimCaseDbService.create({
|
const newClaim = await this.claimCaseDbService.create({
|
||||||
requestNo: claimNo,
|
requestNo: claimNo,
|
||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
@@ -4588,32 +4615,19 @@ export class ClaimRequestManagementService {
|
|||||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
owner: {
|
...(damagedUserId
|
||||||
userId: new Types.ObjectId(currentUserId),
|
? { damagedPartyUserId: new Types.ObjectId(damagedUserId) }
|
||||||
userRole: currentUserParty.role as any,
|
: {}),
|
||||||
|
owner: (() => {
|
||||||
|
const cid = ownerParty.person.clientId;
|
||||||
|
return {
|
||||||
|
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
||||||
|
userRole: ownerParty.role as any,
|
||||||
fullName: currentUserParty.person?.fullName,
|
fullName: currentUserParty.person?.fullName,
|
||||||
userClientKey: currentUserParty.person?.clientId
|
userClientKey: cid ? String(cid) : undefined,
|
||||||
? String(currentUserParty.person.clientId)
|
...(cid ? { clientId: new Types.ObjectId(String(cid)) } : {}),
|
||||||
: undefined,
|
};
|
||||||
// clientId must be the GUILTY party's clientId — their insurer pays the claim
|
|
||||||
...(() => {
|
|
||||||
if (isCarBody) {
|
|
||||||
// CAR_BODY has no guilty party — use the first party's own clientId
|
|
||||||
const firstParty = parties.find((p) => p.role === "FIRST");
|
|
||||||
const cid = firstParty?.person?.clientId;
|
|
||||||
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const guiltyPartyId = String(
|
|
||||||
blameRequest?.expert?.decision?.guiltyPartyId ?? "",
|
|
||||||
);
|
|
||||||
const guiltyParty = parties.find(
|
|
||||||
(p) => String(p.person?.userId) === guiltyPartyId,
|
|
||||||
);
|
|
||||||
const cid = guiltyParty?.person?.clientId;
|
|
||||||
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
|
|
||||||
})(),
|
})(),
|
||||||
},
|
|
||||||
history: [
|
history: [
|
||||||
{
|
{
|
||||||
type: "CLAIM_CREATED",
|
type: "CLAIM_CREATED",
|
||||||
@@ -4719,9 +4733,12 @@ export class ClaimRequestManagementService {
|
|||||||
throw new ConflictException("A claim for this blame case already exists");
|
throw new ConflictException("A claim for this blame case already exists");
|
||||||
}
|
}
|
||||||
const claimNo = await this.generateUniqueClaimNumber();
|
const claimNo = await this.generateUniqueClaimNumber();
|
||||||
const damagedParty = parties.find(
|
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
||||||
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
|
if (!ownerParty?.person?.userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Could not resolve claim owner (guilty party) from blame",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const newClaim = await this.claimCaseDbService.create({
|
const newClaim = await this.claimCaseDbService.create({
|
||||||
requestNo: claimNo,
|
requestNo: claimNo,
|
||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
@@ -4737,12 +4754,15 @@ export class ClaimRequestManagementService {
|
|||||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
|
damagedPartyUserId: new Types.ObjectId(damagedUserId),
|
||||||
owner: {
|
owner: {
|
||||||
userId: new Types.ObjectId(damagedUserId),
|
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
||||||
userRole: damagedParty?.role as any,
|
userRole: ownerParty.role as any,
|
||||||
...(damagedParty?.person?.clientId
|
...(ownerParty.person.clientId
|
||||||
? {
|
? {
|
||||||
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
|
clientId: new Types.ObjectId(
|
||||||
|
String(ownerParty.person.clientId),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
@@ -4776,32 +4796,143 @@ export class ClaimRequestManagementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertActorCanViewClaimV2(
|
/**
|
||||||
|
* True when the actor appears as a non-owner (damaged) party on the claim's
|
||||||
|
* snapshot.parties. This is the fastest, blame-free fallback: the snapshot is
|
||||||
|
* written at claim-creation time and always holds the correct user IDs.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - Any party whose person.userId is in linkedUserIds and is NOT the claim
|
||||||
|
* owner (guilty party) is considered the damaged party.
|
||||||
|
* - If owner.userId is unknown we accept any matching party (CAR_BODY flows).
|
||||||
|
*/
|
||||||
|
private claimSnapshotPartyMatchesAsDamagedParty(
|
||||||
claim: any,
|
claim: any,
|
||||||
currentUserId: string,
|
linkedUserIdStrings: string[],
|
||||||
actor?: { sub: string; role?: string },
|
): boolean {
|
||||||
|
const parties: any[] = claim?.snapshot?.parties ?? [];
|
||||||
|
if (!parties.length) return false;
|
||||||
|
|
||||||
|
const ownerIdStr = claim?.owner?.userId
|
||||||
|
? String(claim.owner.userId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const party of parties) {
|
||||||
|
const partyUserId = party?.person?.userId;
|
||||||
|
if (partyUserId == null) continue;
|
||||||
|
const partyStr = String(partyUserId);
|
||||||
|
|
||||||
|
// Must be this actor
|
||||||
|
if (!linkedUserIdStrings.some((id) => id === partyStr)) continue;
|
||||||
|
|
||||||
|
// Must NOT be the guilty-owner (unless owner is unknown — CAR_BODY)
|
||||||
|
if (ownerIdStr && partyStr === ownerIdStr) continue;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
|
claimCase: any,
|
||||||
|
effectiveUserId: string,
|
||||||
|
actor?: { username?: string },
|
||||||
|
message = "Only the damaged party can perform this action on the claim",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ownerId = claim.owner?.userId?.toString();
|
const linkedUserIds = await resolveLinkedUserIdStrings(
|
||||||
if (ownerId && ownerId === currentUserId) {
|
this.userDbService,
|
||||||
|
{ sub: effectiveUserId, username: actor?.username },
|
||||||
|
);
|
||||||
|
const actorCtx = { sub: effectiveUserId, username: actor?.username };
|
||||||
|
|
||||||
|
// 0. damagedPartyUserId stored directly on the claim (fastest, most reliable)
|
||||||
|
if (ownerUserIdMatchesActor(claimCase?.damagedPartyUserId, linkedUserIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
// 1. Owner (= guilty party for THIRD_PARTY) — kept for backward compat
|
||||||
if (claim.initiatedByFieldExpertId?.toString() === currentUserId) {
|
// on CAR_BODY where owner IS the damaged party.
|
||||||
|
if (ownerUserIdMatchesActor(claimCase?.owner?.userId, linkedUserIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Blame-based resolution (primary path for THIRD_PARTY)
|
||||||
|
if (claimCase?.blameRequestId) {
|
||||||
|
const blame = await this.blameRequestDbService.findById(
|
||||||
|
claimCase.blameRequestId.toString(),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
blame &&
|
||||||
|
blameDamagedPartyMatchesUser(blame, actorCtx, linkedUserIds)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Snapshot-party fallback: claim.snapshot.parties is written at creation
|
||||||
|
// and is reliable even when blame lookup fails or userId mapping differs.
|
||||||
|
if (this.claimSnapshotPartyMatchesAsDamagedParty(claimCase, linkedUserIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActorCanViewClaimV2(
|
||||||
|
claim: any,
|
||||||
|
currentUserId: string,
|
||||||
|
actor?: { sub: string; role?: string; username?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const linkedUserIds = await resolveLinkedUserIdStrings(
|
||||||
|
this.userDbService,
|
||||||
|
{ sub: currentUserId, username: actor?.username },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownerUserIdMatchesActor(claim.owner?.userId, linkedUserIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// damagedPartyUserId is set at claim-creation time — the most direct check.
|
||||||
|
if (ownerUserIdMatchesActor(claim.damagedPartyUserId, linkedUserIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actor?.role || actor.role === RoleEnum.USER) {
|
||||||
|
const userCtx = { sub: currentUserId, username: actor?.username };
|
||||||
|
|
||||||
|
// Any party on the linked blame can VIEW the claim.
|
||||||
|
// (Mutation guards use assertEffectiveUserIsDamagedPartyOnClaim which is
|
||||||
|
// stricter and only allows the damaged party to act.)
|
||||||
if (claim.blameRequestId) {
|
if (claim.blameRequestId) {
|
||||||
const blame = await this.blameRequestDbService.findById(
|
const blame = await this.blameRequestDbService.findById(
|
||||||
claim.blameRequestId.toString(),
|
claim.blameRequestId.toString(),
|
||||||
);
|
);
|
||||||
if (
|
if (blame) {
|
||||||
blame?.expertInitiated &&
|
const parties: any[] = (blame as any).parties ?? [];
|
||||||
blame.initiatedByFieldExpertId &&
|
const isParty = parties.some((p: any) =>
|
||||||
String(blame.initiatedByFieldExpertId) === currentUserId
|
partyPersonMatchesUser(p?.person, userCtx, linkedUserIds),
|
||||||
) {
|
);
|
||||||
|
if (isParty) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot-party fallback (reliable for claims with snapshot written).
|
||||||
|
if (this.claimSnapshotPartyMatchesAsDamagedParty(claim, linkedUserIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||||||
|
const blame = claim.blameRequestId
|
||||||
|
? await this.blameRequestDbService.findById(
|
||||||
|
claim.blameRequestId.toString(),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
|
if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
|
||||||
@@ -4857,9 +4988,10 @@ export class ClaimRequestManagementService {
|
|||||||
return currentUserId;
|
return currentUserId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return claimCase.owner?.userId
|
return (
|
||||||
? String(claimCase.owner.userId)
|
resolveDamagedPartyUserId(blameRequest) ??
|
||||||
: currentUserId;
|
(claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createClaimFromBlameForRegistrarV1(
|
async createClaimFromBlameForRegistrarV1(
|
||||||
@@ -4915,9 +5047,12 @@ export class ClaimRequestManagementService {
|
|||||||
if (existingClaim)
|
if (existingClaim)
|
||||||
throw new ConflictException("A claim for this blame case already exists");
|
throw new ConflictException("A claim for this blame case already exists");
|
||||||
const claimNo = await this.generateUniqueClaimNumber();
|
const claimNo = await this.generateUniqueClaimNumber();
|
||||||
const damagedParty = parties.find(
|
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
||||||
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
|
if (!ownerParty?.person?.userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Could not resolve claim owner (guilty party) from blame",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const newClaim = await this.claimCaseDbService.create({
|
const newClaim = await this.claimCaseDbService.create({
|
||||||
requestNo: claimNo,
|
requestNo: claimNo,
|
||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
@@ -4932,12 +5067,15 @@ export class ClaimRequestManagementService {
|
|||||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
|
damagedPartyUserId: new Types.ObjectId(damagedUserId),
|
||||||
owner: {
|
owner: {
|
||||||
userId: new Types.ObjectId(damagedUserId),
|
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
||||||
userRole: damagedParty?.role as any,
|
userRole: ownerParty.role as any,
|
||||||
...(damagedParty?.person?.clientId
|
...(ownerParty.person.clientId
|
||||||
? {
|
? {
|
||||||
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
|
clientId: new Types.ObjectId(
|
||||||
|
String(ownerParty.person.clientId),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
@@ -5004,15 +5142,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
// 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim)
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
if (
|
claimCase,
|
||||||
!claimCase.owner?.userId ||
|
effectiveUserId,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
actor as { username?: string },
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner (damaged party) can select damaged parts",
|
"Only the claim owner (damaged party) can select damaged parts",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
|
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
|
||||||
if (
|
if (
|
||||||
@@ -5224,15 +5359,13 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
// 2. Validate user is the claim owner (or field expert for IN_PERSON claim)
|
// 2. Validate user is the damaged party (or field expert for IN_PERSON claim)
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner (damaged party) can submit other parts and bank information",
|
"Only the claim owner (damaged party) can submit other parts and bank information",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
|
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
|
||||||
if (
|
if (
|
||||||
@@ -5447,14 +5580,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can view capture requirements",
|
"Only the claim owner can view capture requirements",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Build car-type aware outer-parts lookup (complete source: static catalog).
|
// Build car-type aware outer-parts lookup (complete source: static catalog).
|
||||||
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
|
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
|
||||||
@@ -5743,14 +5874,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can complete this step",
|
"Only the claim owner can complete this step",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||||||
) {
|
) {
|
||||||
@@ -5819,14 +5948,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can upload documents",
|
"Only the claim owner can upload documents",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const step = claimCase.workflow?.currentStep;
|
const step = claimCase.workflow?.currentStep;
|
||||||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
@@ -6165,12 +6292,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException("Only the claim owner can capture parts");
|
"Only the claim owner can capture parts",
|
||||||
}
|
);
|
||||||
|
|
||||||
const capStep = claimCase.workflow?.currentStep;
|
const capStep = claimCase.workflow?.currentStep;
|
||||||
const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||||
@@ -6462,14 +6589,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can upload car capture video",
|
"Only the claim owner can upload car capture video",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileDetail) {
|
if (!fileDetail) {
|
||||||
throw new BadRequestException("Video file is required.");
|
throw new BadRequestException("Video file is required.");
|
||||||
@@ -6585,14 +6710,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can submit an objection",
|
"Only the claim owner can submit an objection",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (claimCase.evaluation?.objection?.submittedAt) {
|
if (claimCase.evaluation?.objection?.submittedAt) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
@@ -6806,14 +6929,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner can submit a satisfaction rating.",
|
"Only the claim owner can submit a satisfaction rating.",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
|
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -6911,14 +7032,12 @@ export class ClaimRequestManagementService {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
actor?.role,
|
actor?.role,
|
||||||
);
|
);
|
||||||
if (
|
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||||||
!claimCase.owner?.userId ||
|
claimCase,
|
||||||
claimCase.owner.userId.toString() !== effectiveUserId
|
effectiveUserId,
|
||||||
) {
|
actor as { username?: string },
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only the claim owner (damaged party) can sign at this step.",
|
"Only the claim owner (damaged party) can sign at this step.",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) {
|
if (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -7179,25 +7298,24 @@ export class ClaimRequestManagementService {
|
|||||||
try {
|
try {
|
||||||
let claims: any[];
|
let claims: any[];
|
||||||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||||||
|
const expertOid = new Types.ObjectId(currentUserId);
|
||||||
const expertBlameIds = await this.blameRequestDbService
|
const expertBlameIds = await this.blameRequestDbService
|
||||||
.find(
|
.find(
|
||||||
{
|
{
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
initiatedByFieldExpertId: new Types.ObjectId(currentUserId),
|
initiatedByFieldExpertId: expertOid,
|
||||||
},
|
},
|
||||||
{ select: "_id", lean: true },
|
{ select: "_id", lean: true },
|
||||||
)
|
)
|
||||||
.then((docs) => docs.map((d) => (d as any)._id));
|
.then((docs) => docs.map((d) => (d as any)._id));
|
||||||
|
const claimOr: Record<string, unknown>[] = [
|
||||||
|
{ initiatedByFieldExpertId: expertOid },
|
||||||
|
];
|
||||||
|
if (expertBlameIds.length > 0) {
|
||||||
|
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||||||
|
}
|
||||||
claims = await this.claimCaseDbService.find(
|
claims = await this.claimCaseDbService.find(
|
||||||
{
|
{ $or: claimOr },
|
||||||
$or: [
|
|
||||||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
|
||||||
...(expertBlameIds.length
|
|
||||||
? [{ blameRequestId: { $in: expertBlameIds } }]
|
|
||||||
: []),
|
|
||||||
{ initiatedByFieldExpertId: new Types.ObjectId(currentUserId) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ lean: true },
|
{ lean: true },
|
||||||
);
|
);
|
||||||
} else if (actor?.role === RoleEnum.REGISTRAR) {
|
} else if (actor?.role === RoleEnum.REGISTRAR) {
|
||||||
@@ -7223,10 +7341,102 @@ export class ClaimRequestManagementService {
|
|||||||
{ lean: true },
|
{ lean: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
claims = await this.claimCaseDbService.find(
|
const linkedUserIds = await resolveLinkedUserIdStrings(
|
||||||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
this.userDbService,
|
||||||
{ lean: true },
|
{
|
||||||
|
sub: currentUserId,
|
||||||
|
username: (actor as { username?: string })?.username,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
const actorCtx = {
|
||||||
|
sub: currentUserId,
|
||||||
|
username: (actor as { username?: string })?.username,
|
||||||
|
};
|
||||||
|
const partyBlameOr = buildBlamePartyAccessOrConditions(
|
||||||
|
actorCtx,
|
||||||
|
linkedUserIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const idVariants = collectUserIdVariants(...linkedUserIds);
|
||||||
|
|
||||||
|
const ownerOr = buildOwnerUserIdAccessFilter(idVariants);
|
||||||
|
|
||||||
|
// damagedPartyUserId filter — direct match without blame lookup
|
||||||
|
const damagedOr =
|
||||||
|
idVariants.length === 1
|
||||||
|
? { damagedPartyUserId: idVariants[0] }
|
||||||
|
: idVariants.length > 1
|
||||||
|
? { damagedPartyUserId: { $in: idVariants } }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Build snapshot-party query: claim.snapshot.parties.person.userId
|
||||||
|
// is the most direct and reliable path — it's copied from the blame
|
||||||
|
// at claim-creation time and always has the correct damaged-party userId.
|
||||||
|
const snapshotPartyFilter =
|
||||||
|
idVariants.length === 1
|
||||||
|
? { "snapshot.parties.person.userId": idVariants[0] }
|
||||||
|
: idVariants.length > 1
|
||||||
|
? { "snapshot.parties.person.userId": { $in: idVariants } }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [ownerClaims, damagedDirectClaims, snapshotClaims, candidateBlames] =
|
||||||
|
await Promise.all([
|
||||||
|
ownerOr
|
||||||
|
? this.claimCaseDbService.find(ownerOr as any, { lean: true })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
damagedOr
|
||||||
|
? this.claimCaseDbService.find(damagedOr as any, { lean: true })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
snapshotPartyFilter
|
||||||
|
? this.claimCaseDbService.find(snapshotPartyFilter as any, {
|
||||||
|
lean: true,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
partyBlameOr.length
|
||||||
|
? this.blameRequestDbService.find(
|
||||||
|
partyBlameOr.length === 1
|
||||||
|
? (partyBlameOr[0] as any)
|
||||||
|
: ({ $or: partyBlameOr } as any),
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
select: "type parties expert.decision blameStatus",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Any blame where the user is a party (not just damaged) surfaces
|
||||||
|
// the linked claim. Mutation guards protect against acting as the
|
||||||
|
// wrong party; viewing is intentionally open to all parties.
|
||||||
|
const partyBlameIds = (candidateBlames as any[]).map((b) => b._id);
|
||||||
|
|
||||||
|
const damagedClaims =
|
||||||
|
partyBlameIds.length > 0
|
||||||
|
? await this.claimCaseDbService.find(
|
||||||
|
{ blameRequestId: { $in: partyBlameIds } },
|
||||||
|
{ lean: true },
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Merge: owner claims + blame-resolved damaged claims + snapshot-party claims
|
||||||
|
// snapshotClaims may include guilty-owner claims too — filter them out here.
|
||||||
|
const snapshotDamagedClaims = (snapshotClaims as any[]).filter((c) => {
|
||||||
|
if (!c?.owner?.userId) return true; // unknown owner: allow
|
||||||
|
const ownerStr = String(c.owner.userId);
|
||||||
|
// Exclude if the user IS the owner (already in ownerClaims)
|
||||||
|
return !linkedUserIds.some((id) => id === ownerStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byId = new Map<string, any>();
|
||||||
|
for (const c of [
|
||||||
|
...(ownerClaims as any[]),
|
||||||
|
...(damagedDirectClaims as any[]),
|
||||||
|
...(damagedClaims as any[]),
|
||||||
|
...snapshotDamagedClaims,
|
||||||
|
]) {
|
||||||
|
byId.set(String(c._id), c);
|
||||||
|
}
|
||||||
|
claims = [...byId.values()];
|
||||||
}
|
}
|
||||||
const list = (claims as any[]).map((c) => ({
|
const list = (claims as any[]).map((c) => ({
|
||||||
claimRequestId: c._id.toString(),
|
claimRequestId: c._id.toString(),
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export class ClaimCase {
|
|||||||
@Prop({ type: Types.ObjectId, index: true })
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||||
|
* Stored here so view/list access does not require an extra blame lookup.
|
||||||
|
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
|
||||||
|
*/
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
damagedPartyUserId?: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
||||||
workflow?: ClaimWorkflow;
|
workflow?: ClaimWorkflow;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assertBlameCaseForExpertTenant,
|
assertBlameCaseForExpertTenant,
|
||||||
blameCaseAccessibleToExpert,
|
blameCaseAccessibleToExpert,
|
||||||
|
blameCaseInitiatedByFieldExpert,
|
||||||
requireActorClientKey,
|
requireActorClientKey,
|
||||||
} from "src/helpers/tenant-scope";
|
} from "src/helpers/tenant-scope";
|
||||||
import {
|
import {
|
||||||
@@ -436,8 +437,9 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files only.
|
* Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created
|
||||||
* IN_PERSON flows are AGREED and handled outside this panel; completed blames appear in expert-claim.
|
* (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually
|
||||||
|
* via expert-claim after blame completion.
|
||||||
*/
|
*/
|
||||||
private async getFieldExpertBlameListV2(
|
private async getFieldExpertBlameListV2(
|
||||||
actor: { sub: string },
|
actor: { sub: string },
|
||||||
@@ -448,7 +450,6 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
const visibleCases = (await this.blameRequestDbService.find(
|
const visibleCases = (await this.blameRequestDbService.find(
|
||||||
{
|
{
|
||||||
blameStatus: BlameStatus.DISAGREEMENT,
|
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
initiatedByFieldExpertId: expertOid,
|
initiatedByFieldExpertId: expertOid,
|
||||||
},
|
},
|
||||||
@@ -949,7 +950,12 @@ export class ExpertBlameService {
|
|||||||
// Tenant check
|
// Tenant check
|
||||||
assertBlameCaseForExpertTenant(doc, actor);
|
assertBlameCaseForExpertTenant(doc, actor);
|
||||||
|
|
||||||
if (doc.type === BlameRequestType.CAR_BODY) {
|
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
|
||||||
|
|
||||||
|
if (
|
||||||
|
doc.type === BlameRequestType.CAR_BODY &&
|
||||||
|
!isFieldExpertOwner
|
||||||
|
) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"CAR_BODY type requests are automatically handled and do not require expert review.",
|
"CAR_BODY type requests are automatically handled and do not require expert review.",
|
||||||
);
|
);
|
||||||
@@ -978,6 +984,7 @@ export class ExpertBlameService {
|
|||||||
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isFieldExpertOwner) {
|
||||||
// Access gates — must satisfy at least one bucket
|
// Access gates — must satisfy at least one bucket
|
||||||
const isAvailable =
|
const isAvailable =
|
||||||
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
|
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
|
||||||
@@ -1002,6 +1009,7 @@ export class ExpertBlameService {
|
|||||||
"You do not have permission to view this request.",
|
"You do not have permission to view this request.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build evidence URLs
|
// Build evidence URLs
|
||||||
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
||||||
@@ -1158,7 +1166,12 @@ export class ExpertBlameService {
|
|||||||
*/
|
*/
|
||||||
async assignBlameCaseForReviewV2(
|
async assignBlameCaseForReviewV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
actor: {
|
||||||
|
sub: string;
|
||||||
|
fullName?: string;
|
||||||
|
clientKey?: string;
|
||||||
|
role?: string;
|
||||||
|
},
|
||||||
): Promise<ExpertFileAssignResultDto> {
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
@@ -1169,6 +1182,8 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
assertBlameCaseForExpertTenant(request, actor);
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
|
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor);
|
||||||
|
|
||||||
if (request.type === BlameRequestType.CAR_BODY) {
|
if (request.type === BlameRequestType.CAR_BODY) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1177,9 +1192,17 @@ export class ExpertBlameService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.blameStatus !== BlameStatus.DISAGREEMENT) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is not available for expert review",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
!isFieldExpertOwner &&
|
||||||
request.blameStatus !== BlameStatus.DISAGREEMENT
|
request.status !== CaseStatus.WAITING_FOR_EXPERT
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1188,6 +1211,26 @@ export class ExpertBlameService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFieldExpertOwner && request.expert?.decision) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "This request already has an expert decision",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isFieldExpertOwner &&
|
||||||
|
request.status !== CaseStatus.WAITING_FOR_EXPERT
|
||||||
|
) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message:
|
||||||
|
"Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.expertInitiated &&
|
request.expertInitiated &&
|
||||||
request.initiatedByFieldExpertId &&
|
request.initiatedByFieldExpertId &&
|
||||||
@@ -1272,7 +1315,6 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
const assignFilter: Record<string, unknown> = {
|
const assignFilter: Record<string, unknown> = {
|
||||||
_id: new Types.ObjectId(requestId),
|
_id: new Types.ObjectId(requestId),
|
||||||
status: CaseStatus.WAITING_FOR_EXPERT,
|
|
||||||
blameStatus: BlameStatus.DISAGREEMENT,
|
blameStatus: BlameStatus.DISAGREEMENT,
|
||||||
$and: [
|
$and: [
|
||||||
{
|
{
|
||||||
@@ -1293,8 +1335,11 @@ export class ExpertBlameService {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.expertInitiated) {
|
if (isFieldExpertOwner) {
|
||||||
assignFilter.initiatedByFieldExpertId = expertOid;
|
assignFilter.initiatedByFieldExpertId = expertOid;
|
||||||
|
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||||
|
} else {
|
||||||
|
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ export class ExpertBlameV2Controller {
|
|||||||
summary: "List blame cases for expert review (V2)",
|
summary: "List blame cases for expert review (V2)",
|
||||||
description:
|
description:
|
||||||
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
|
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
|
||||||
"Field experts (`field_expert`): only **DISAGREEMENT** files they initiated that need expert review (e.g. LINK disputes). " +
|
"Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " +
|
||||||
"IN_PERSON expert-initiated blames are usually `AGREED` and are managed via expert-initiated / request-management APIs; after completion, use expert-claim. " +
|
|
||||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
||||||
})
|
})
|
||||||
async findAll(
|
async findAll(
|
||||||
|
|||||||
@@ -355,6 +355,28 @@ export class ExpertClaimService {
|
|||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load linked blame when resolving field-expert ownership on a claim. */
|
||||||
|
private async loadBlameForClaim(
|
||||||
|
claim: { blameRequestId?: unknown } | null | undefined,
|
||||||
|
) {
|
||||||
|
const id = claim?.blameRequestId?.toString?.();
|
||||||
|
if (!id) return null;
|
||||||
|
return this.blameRequestDbService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertExpertActorOnClaim(claim: any, actor: any): Promise<void> {
|
||||||
|
const blame = await this.loadBlameForClaim(claim);
|
||||||
|
assertClaimCaseForExpertActor(claim, actor, blame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fieldExpertOwnsClaim(
|
||||||
|
claim: any,
|
||||||
|
actor: { sub: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const blame = await this.loadBlameForClaim(claim);
|
||||||
|
return claimCaseInitiatedByFieldExpert(claim, actor, blame);
|
||||||
|
}
|
||||||
|
|
||||||
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
||||||
private async snapshotDamageExpert(actorId: string) {
|
private async snapshotDamageExpert(actorId: string) {
|
||||||
const doc = await this.damageExpertDbService.findById(actorId);
|
const doc = await this.damageExpertDbService.findById(actorId);
|
||||||
@@ -2167,7 +2189,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
|
|
||||||
@@ -2435,14 +2457,37 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
|
const isFieldExpertOwner =
|
||||||
|
(actor as any).role === RoleEnum.FIELD_EXPERT &&
|
||||||
|
(await this.fieldExpertOwnsClaim(claim, actor));
|
||||||
const isFactorValidationLock =
|
const isFactorValidationLock =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
const isDamageReviewLock =
|
const isDamageReviewLock =
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
|
const isExpertReviewingReentry =
|
||||||
|
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||||||
|
const isResendPending =
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||||
|
const isTerminalClaim =
|
||||||
|
claim.status === ClaimCaseStatus.COMPLETED ||
|
||||||
|
claim.status === ClaimCaseStatus.CANCELLED;
|
||||||
|
const isFieldExpertPortfolioLock =
|
||||||
|
isFieldExpertOwner &&
|
||||||
|
!isTerminalClaim &&
|
||||||
|
!isDamageReviewLock &&
|
||||||
|
!isFactorValidationLock &&
|
||||||
|
!isExpertReviewingReentry &&
|
||||||
|
!isResendPending;
|
||||||
|
|
||||||
if (!isDamageReviewLock && !isFactorValidationLock) {
|
if (
|
||||||
|
!isDamageReviewLock &&
|
||||||
|
!isFactorValidationLock &&
|
||||||
|
!isExpertReviewingReentry &&
|
||||||
|
!isResendPending &&
|
||||||
|
!isFieldExpertPortfolioLock
|
||||||
|
) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
success: false,
|
success: false,
|
||||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
@@ -2490,11 +2535,15 @@ export class ExpertClaimService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
||||||
|
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
|
||||||
|
const actorRoleLabel = isFieldExpertOwner
|
||||||
|
? ("field_expert" as const)
|
||||||
|
: ("damage_expert" as const);
|
||||||
|
|
||||||
const lockedByPayload = {
|
const lockedByPayload = {
|
||||||
actorId: expertOid,
|
actorId: expertOid,
|
||||||
actorName: actor.fullName,
|
actorName: actor.fullName,
|
||||||
actorRole: "damage_expert" as const,
|
actorRole: actorRoleLabel,
|
||||||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2503,6 +2552,8 @@ export class ExpertClaimService {
|
|||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
"workflow.expiredAt": expiredAt,
|
"workflow.expiredAt": expiredAt,
|
||||||
"workflow.lockedBy": lockedByPayload,
|
"workflow.lockedBy": lockedByPayload,
|
||||||
|
...(isDamageReviewLock || isExpertReviewingReentry || isResendPending
|
||||||
|
? {
|
||||||
"workflow.preLockQueueSnapshot": {
|
"workflow.preLockQueueSnapshot": {
|
||||||
claimStatus: claim.claimStatus,
|
claimStatus: claim.claimStatus,
|
||||||
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||||||
@@ -2518,18 +2569,22 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
type: "CLAIM_ASSIGNED",
|
type: "CLAIM_ASSIGNED",
|
||||||
actor: {
|
actor: {
|
||||||
actorId: expertOid,
|
actorId: expertOid,
|
||||||
actorName: actor.fullName,
|
actorName: actor.fullName,
|
||||||
actorType: "damage_expert",
|
actorType,
|
||||||
},
|
},
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
metadata: {
|
metadata: {
|
||||||
note: isFactorValidationLock
|
note: isFactorValidationLock
|
||||||
? "Expert assigned for factor validation review"
|
? "Expert assigned for factor validation review"
|
||||||
|
: isFieldExpertPortfolioLock
|
||||||
|
? "Field expert portfolio lock (no status change)"
|
||||||
: "Expert assigned via review assign endpoint",
|
: "Expert assigned via review assign endpoint",
|
||||||
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
||||||
},
|
},
|
||||||
@@ -2541,7 +2596,11 @@ export class ExpertClaimService {
|
|||||||
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
|
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lockUpdate: Record<string, unknown> = isFactorValidationLock
|
const lockUpdate: Record<string, unknown> =
|
||||||
|
isFactorValidationLock ||
|
||||||
|
isExpertReviewingReentry ||
|
||||||
|
isResendPending ||
|
||||||
|
isFieldExpertPortfolioLock
|
||||||
? baseLockUpdate
|
? baseLockUpdate
|
||||||
: {
|
: {
|
||||||
...baseLockUpdate,
|
...baseLockUpdate,
|
||||||
@@ -2581,6 +2640,13 @@ export class ExpertClaimService {
|
|||||||
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
} else if (isExpertReviewingReentry) {
|
||||||
|
assignFilter.status = ClaimCaseStatus.EXPERT_REVIEWING;
|
||||||
|
} else if (isResendPending) {
|
||||||
|
assignFilter.status = ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||||
|
} else if (isFieldExpertPortfolioLock) {
|
||||||
|
assignFilter.initiatedByFieldExpertId = expertOid;
|
||||||
|
assignFilter.status = claim.status;
|
||||||
} else {
|
} else {
|
||||||
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
}
|
}
|
||||||
@@ -2634,7 +2700,13 @@ export class ExpertClaimService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
|
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
|
||||||
if (ownerPhone) {
|
if (
|
||||||
|
ownerPhone &&
|
||||||
|
isDamageReviewLock &&
|
||||||
|
!isFieldExpertPortfolioLock &&
|
||||||
|
!isExpertReviewingReentry &&
|
||||||
|
!isResendPending
|
||||||
|
) {
|
||||||
const expertLastName =
|
const expertLastName =
|
||||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
@@ -2728,7 +2800,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -2893,7 +2965,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -3564,8 +3636,23 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
query: ListQueryV2Dto = {},
|
query: ListQueryV2Dto = {},
|
||||||
): Promise<GetClaimListV2ResponseDto> {
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
|
const expertOid = new Types.ObjectId(actor.sub);
|
||||||
|
const expertBlameIds = await this.blameRequestDbService
|
||||||
|
.find(
|
||||||
|
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
|
||||||
|
{ select: "_id", lean: true },
|
||||||
|
)
|
||||||
|
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
|
||||||
|
|
||||||
|
const claimOr: Record<string, unknown>[] = [
|
||||||
|
{ initiatedByFieldExpertId: expertOid },
|
||||||
|
];
|
||||||
|
if (expertBlameIds.length > 0) {
|
||||||
|
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||||||
|
}
|
||||||
|
|
||||||
const claims = (await this.claimCaseDbService.find({
|
const claims = (await this.claimCaseDbService.find({
|
||||||
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
|
$or: claimOr,
|
||||||
})) as any[];
|
})) as any[];
|
||||||
|
|
||||||
const blameIds = [
|
const blameIds = [
|
||||||
@@ -3993,7 +4080,8 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
const linkedBlame = await this.loadBlameForClaim(claim);
|
||||||
|
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||||||
|
|
||||||
// Variables used both in the gate block and in the detail-build section below
|
// Variables used both in the gate block and in the detail-build section below
|
||||||
const isDamageExpertPhase =
|
const isDamageExpertPhase =
|
||||||
@@ -4004,7 +4092,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
// Field experts can always view their own initiated claims regardless of status
|
// Field experts can always view their own initiated claims regardless of status
|
||||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||||
if (!claimCaseInitiatedByFieldExpert(claim, actor)) {
|
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||||||
throw new ForbiddenException("This claim was not initiated by you.");
|
throw new ForbiddenException("This claim was not initiated by you.");
|
||||||
}
|
}
|
||||||
// Fall through to the detail build below (skip the damage-expert gate)
|
// Fall through to the detail build below (skip the damage-expert gate)
|
||||||
@@ -4300,9 +4388,12 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
||||||
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
|
private async assertExpertCanEditClaimDuringReviewV2(
|
||||||
|
claim: any,
|
||||||
|
actor: any,
|
||||||
|
): Promise<void> {
|
||||||
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
||||||
@@ -4326,7 +4417,7 @@ export class ExpertClaimService {
|
|||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||||
|
|
||||||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const selectedNorm = normalizeDamageSelectedParts(
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
@@ -4380,7 +4471,7 @@ export class ExpertClaimService {
|
|||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||||
|
|
||||||
const vehicleSet: Record<string, string> = {};
|
const vehicleSet: Record<string, string> = {};
|
||||||
if (body.carName != null && String(body.carName).trim()) {
|
if (body.carName != null && String(body.carName).trim()) {
|
||||||
@@ -4536,7 +4627,7 @@ export class ExpertClaimService {
|
|||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
assertClaimCaseForExpertActor(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
@@ -4564,7 +4655,7 @@ export class ExpertClaimService {
|
|||||||
previousNorm,
|
previousNorm,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextNorm = body.selectedParts.map((p) =>
|
const nextNormRaw = body.selectedParts.map((p) =>
|
||||||
sanitizeDamageSelectedPartV2(
|
sanitizeDamageSelectedPartV2(
|
||||||
{
|
{
|
||||||
id: p.id ?? null,
|
id: p.id ?? null,
|
||||||
@@ -4578,6 +4669,20 @@ export class ExpertClaimService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drop ghost entries: internal parts with neither a catalog id nor a name
|
||||||
|
// (these are frontend artifacts from uninitialised form rows).
|
||||||
|
const nextNorm = nextNormRaw.filter((p) => {
|
||||||
|
if (p.id != null) return true;
|
||||||
|
return String(p.name ?? "").trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextNorm.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"selectedParts must contain at least one identifiable part. " +
|
||||||
|
"Each entry must have a numeric catalog id or a non-empty name.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const matchPreviousIndex = (
|
const matchPreviousIndex = (
|
||||||
next: (typeof nextNorm)[0],
|
next: (typeof nextNorm)[0],
|
||||||
previous: typeof previousNorm,
|
previous: typeof previousNorm,
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available claim requests for damage expert",
|
summary: "List available claim requests for damage expert",
|
||||||
description:
|
description:
|
||||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
"Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " +
|
||||||
|
"Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
|
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
|
||||||
async getClaimListV2(
|
async getClaimListV2(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.
|
|||||||
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 { 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 { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
@@ -62,6 +63,7 @@ export class ExpertInsurerService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
|
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
@@ -74,6 +76,26 @@ export class ExpertInsurerService {
|
|||||||
private readonly claimSignDbService: ClaimSignDbService,
|
private readonly claimSignDbService: ClaimSignDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all expert IDs (blame-expert, damage-expert, field-expert) that
|
||||||
|
* belong to this insurer's clientKey in a single parallel fetch.
|
||||||
|
*/
|
||||||
|
private async getAllRosterExpertIds(
|
||||||
|
clientObjectId: Types.ObjectId,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||||
|
const [experts, damageExperts, fieldExperts] = await Promise.all([
|
||||||
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
|
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||||
|
]);
|
||||||
|
return new Set<string>(
|
||||||
|
[...experts, ...damageExperts, ...fieldExperts].map((e: any) =>
|
||||||
|
String(e._id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getClientId(actorOrId: any): Types.ObjectId {
|
private getClientId(actorOrId: any): Types.ObjectId {
|
||||||
const raw =
|
const raw =
|
||||||
typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
||||||
@@ -786,13 +808,26 @@ export class ExpertInsurerService {
|
|||||||
private async getClientClaimFiles(
|
private async getClientClaimFiles(
|
||||||
clientObjectId: Types.ObjectId,
|
clientObjectId: Types.ObjectId,
|
||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
const all = (await this.claimCaseDbService.find(
|
const [all, rosterExpertIds] = await Promise.all([
|
||||||
{},
|
this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>,
|
||||||
{ lean: true },
|
this.getAllRosterExpertIds(clientObjectId),
|
||||||
)) as any[];
|
]);
|
||||||
const idStr = String(clientObjectId);
|
const idStr = String(clientObjectId);
|
||||||
|
|
||||||
return all
|
return all
|
||||||
.filter((f) => claimCaseTouchesClient(f, idStr))
|
.filter((f) => {
|
||||||
|
// Primary: owner.clientId matches this insurer (guilty party's insurance)
|
||||||
|
if (claimCaseTouchesClient(f, idStr)) return true;
|
||||||
|
// Secondary: initiated or evaluated by one of this insurer's experts
|
||||||
|
// (covers field-expert IN_PERSON flow where owner.clientId ≠ this insurer)
|
||||||
|
const fieldExpertId = f?.initiatedByFieldExpertId
|
||||||
|
? String(f.initiatedByFieldExpertId)
|
||||||
|
: null;
|
||||||
|
if (fieldExpertId && rosterExpertIds.has(fieldExpertId)) return true;
|
||||||
|
const damageExpertId = this.claimDamageExpertActorId(f);
|
||||||
|
if (damageExpertId && rosterExpertIds.has(damageExpertId)) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
.map((f) => this.normalizeClaimCase(f));
|
.map((f) => this.normalizeClaimCase(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,14 +838,16 @@ export class ExpertInsurerService {
|
|||||||
) {
|
) {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
||||||
|
await Promise.all([
|
||||||
this.expertDbService.findAll(ckFilter as never),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll(ckFilter as never),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
|
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||||
this.getClientBlameFiles(clientObjectId),
|
this.getClientBlameFiles(clientObjectId),
|
||||||
this.getClientClaimFiles(clientObjectId),
|
this.getClientClaimFiles(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allExpertsRaw = [...experts, ...damageExperts];
|
const allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts];
|
||||||
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
||||||
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
||||||
clientObjectId,
|
clientObjectId,
|
||||||
@@ -890,6 +927,7 @@ export class ExpertInsurerService {
|
|||||||
const allExperts = [
|
const allExperts = [
|
||||||
...experts.map((e) => mapExpertRow(e, "blame")),
|
...experts.map((e) => mapExpertRow(e, "blame")),
|
||||||
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
||||||
|
...fieldExperts.map((e) => mapExpertRow(e, "blame")),
|
||||||
];
|
];
|
||||||
|
|
||||||
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||||
@@ -960,12 +998,15 @@ export class ExpertInsurerService {
|
|||||||
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||||||
const clientOid = this.getClientId(insurerClientKey);
|
const clientOid = this.getClientId(insurerClientKey);
|
||||||
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
||||||
const [experts, damageExperts] = await Promise.all([
|
const [experts, damageExperts, fieldExperts] = await Promise.all([
|
||||||
this.expertDbService.findAll(ckFilter as never),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll(ckFilter as never),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
|
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||||
]);
|
]);
|
||||||
const id = String(expertObjectId);
|
const id = String(expertObjectId);
|
||||||
const onBlameRoster = experts.some((e) => String(e._id) === id);
|
const onBlameRoster =
|
||||||
|
experts.some((e) => String(e._id) === id) ||
|
||||||
|
fieldExperts.some((e) => String(e._id) === id);
|
||||||
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
||||||
if (!onBlameRoster && !onClaimRoster) {
|
if (!onBlameRoster && !onClaimRoster) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -973,34 +1014,41 @@ export class ExpertInsurerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientKeyStr = String(clientOid);
|
// Gather all files this expert touched — blame and claim — regardless of
|
||||||
|
// which insurance company's party appears on the file. The roster check
|
||||||
|
// above already ensures the expert belongs to THIS insurer, so showing all
|
||||||
|
// files they handled is the correct scope.
|
||||||
|
const [allBlames, allClaims] = await Promise.all([
|
||||||
|
onBlameRoster
|
||||||
|
? (this.blameRequestDbService.find({}, { lean: true }) as Promise<any[]>)
|
||||||
|
: Promise.resolve([] as any[]),
|
||||||
|
(this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
// Blame files handled by a field/blame expert
|
||||||
if (onBlameRoster) {
|
if (onBlameRoster) {
|
||||||
const blames = (await this.blameRequestDbService.find(
|
for (const b of allBlames) {
|
||||||
{},
|
if (this.blameFieldExpertIdCandidates(b).includes(id)) {
|
||||||
{ lean: true },
|
result.push(this.mapBlameFileSummaryForInsurerExpert(b));
|
||||||
)) as any[];
|
|
||||||
return blames
|
|
||||||
.filter(
|
|
||||||
(b) =>
|
|
||||||
blameCaseTouchesClient(b, clientKeyStr) &&
|
|
||||||
this.blameFieldExpertIdCandidates(b).includes(id),
|
|
||||||
)
|
|
||||||
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
|
|
||||||
}
|
}
|
||||||
if (onClaimRoster) {
|
|
||||||
const claims = (await this.claimCaseDbService.find(
|
|
||||||
{},
|
|
||||||
{ lean: true },
|
|
||||||
)) as any[];
|
|
||||||
return claims
|
|
||||||
.filter(
|
|
||||||
(c) =>
|
|
||||||
claimCaseTouchesClient(c, clientKeyStr) &&
|
|
||||||
this.claimDamageExpertActorId(c) === id,
|
|
||||||
)
|
|
||||||
.map((c) => this.mapClaimFileSummaryForInsurerExpert(c));
|
|
||||||
}
|
}
|
||||||
return [];
|
}
|
||||||
|
|
||||||
|
// Claim files: initiated by this field expert OR evaluated by this damage expert
|
||||||
|
for (const c of allClaims) {
|
||||||
|
const isFieldExpertClaim =
|
||||||
|
c?.initiatedByFieldExpertId &&
|
||||||
|
String(c.initiatedByFieldExpertId) === id;
|
||||||
|
const isDamageExpertClaim =
|
||||||
|
onClaimRoster && this.claimDamageExpertActorId(c) === id;
|
||||||
|
if (isFieldExpertClaim || isDamageExpertClaim) {
|
||||||
|
result.push(this.mapClaimFileSummaryForInsurerExpert(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateInsurerFileRating(rating: FileRating) {
|
private validateInsurerFileRating(rating: FileRating) {
|
||||||
@@ -1697,9 +1745,11 @@ export class ExpertInsurerService {
|
|||||||
|
|
||||||
const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
|
const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
|
||||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
|
||||||
|
await Promise.all([
|
||||||
this.expertDbService.findAll(ckFilter as never),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll(ckFilter as never),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
|
this.fieldExpertDbService.findAll(ckFilter as never),
|
||||||
this.getClientBlameFiles(clientObjectId),
|
this.getClientBlameFiles(clientObjectId),
|
||||||
this.getClientClaimFiles(clientObjectId),
|
this.getClientClaimFiles(clientObjectId),
|
||||||
]);
|
]);
|
||||||
@@ -1707,7 +1757,8 @@ export class ExpertInsurerService {
|
|||||||
const activeExpertsByBranch = new Map<string, Set<string>>();
|
const activeExpertsByBranch = new Map<string, Set<string>>();
|
||||||
const expertBranchById = new Map<string, string>();
|
const expertBranchById = new Map<string, string>();
|
||||||
const claimExpertBranchById = new Map<string, string>();
|
const claimExpertBranchById = new Map<string, string>();
|
||||||
for (const e of experts as any[]) {
|
// blame-expert + field-expert both handle blame/IN_PERSON files
|
||||||
|
for (const e of [...(experts as any[]), ...(fieldExperts as any[])]) {
|
||||||
const bid = e?.branchId ? String(e.branchId) : "";
|
const bid = e?.branchId ? String(e.branchId) : "";
|
||||||
if (!bid || !branchIdSet.has(bid)) continue;
|
if (!bid || !branchIdSet.has(bid)) continue;
|
||||||
const eid = String(e?._id || "");
|
const eid = String(e?._id || "");
|
||||||
@@ -1742,9 +1793,11 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const b of blameFiles as any[]) {
|
for (const b of blameFiles as any[]) {
|
||||||
const expertId = String(b?.expert?.assignedExpertId || "");
|
// Resolve expert via assignment OR field-expert initiation
|
||||||
if (!expertId) continue;
|
const candidateIds = this.blameFieldExpertIdCandidates(b);
|
||||||
const bid = expertBranchById.get(expertId);
|
const bid = candidateIds
|
||||||
|
.map((eid) => expertBranchById.get(eid))
|
||||||
|
.find(Boolean);
|
||||||
if (!bid) continue;
|
if (!bid) continue;
|
||||||
const fileKey = String(b?.publicId || b?._id || "");
|
const fileKey = String(b?.publicId || b?._id || "");
|
||||||
if (!fileKey) continue;
|
if (!fileKey) continue;
|
||||||
@@ -1756,9 +1809,17 @@ export class ExpertInsurerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const c of claimFiles as any[]) {
|
for (const c of claimFiles as any[]) {
|
||||||
const expertId = String(this.claimDamageExpertActorId(c) || "");
|
// Field-expert-initiated claims
|
||||||
|
const fieldExpertId = c?.initiatedByFieldExpertId
|
||||||
|
? String(c.initiatedByFieldExpertId)
|
||||||
|
: null;
|
||||||
|
const damageExpertId = this.claimDamageExpertActorId(c);
|
||||||
|
const expertId = fieldExpertId ?? damageExpertId ?? "";
|
||||||
if (!expertId) continue;
|
if (!expertId) continue;
|
||||||
const bid = claimExpertBranchById.get(expertId);
|
const bid =
|
||||||
|
expertBranchById.get(expertId) ??
|
||||||
|
claimExpertBranchById.get(expertId) ??
|
||||||
|
undefined;
|
||||||
if (!bid) continue;
|
if (!bid) continue;
|
||||||
const fileKey = String(c?.publicId || c?._id || "");
|
const fileKey = String(c?.publicId || c?._id || "");
|
||||||
if (!fileKey) continue;
|
if (!fileKey) continue;
|
||||||
|
|||||||
58
src/helpers/blame-damaged-party.spec.ts
Normal file
58
src/helpers/blame-damaged-party.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { partyPersonMatchesUser } from "./iran-mobile";
|
||||||
|
import {
|
||||||
|
blameDamagedPartyMatchesUser,
|
||||||
|
resolveDamagedPartyPerson,
|
||||||
|
resolveGuiltyPartyUserId,
|
||||||
|
} from "./blame-damaged-party";
|
||||||
|
|
||||||
|
describe("blame damaged party helpers", () => {
|
||||||
|
const guiltyId = new Types.ObjectId();
|
||||||
|
const damagedId = new Types.ObjectId();
|
||||||
|
|
||||||
|
const thirdPartyBlame = {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
expert: { decision: { guiltyPartyId: guiltyId } },
|
||||||
|
parties: [
|
||||||
|
{ role: "FIRST", person: { userId: guiltyId, phoneNumber: "09111111111" } },
|
||||||
|
{ role: "SECOND", person: { userId: damagedId, phoneNumber: "09912356917" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves guilty party id from expert decision", () => {
|
||||||
|
expect(resolveGuiltyPartyUserId(thirdPartyBlame)).toBe(String(guiltyId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves damaged party person as non-guilty", () => {
|
||||||
|
expect(resolveDamagedPartyPerson(thirdPartyBlame)).toEqual(
|
||||||
|
thirdPartyBlame.parties[1].person,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches damaged party by userId even when claim.owner is guilty", () => {
|
||||||
|
expect(
|
||||||
|
blameDamagedPartyMatchesUser(
|
||||||
|
thirdPartyBlame,
|
||||||
|
{ sub: String(damagedId), username: "09912356917" },
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
blameDamagedPartyMatchesUser(
|
||||||
|
thirdPartyBlame,
|
||||||
|
{ sub: String(guiltyId), username: "09111111111" },
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches damaged party by phone when userId differs (duplicate accounts)", () => {
|
||||||
|
expect(
|
||||||
|
partyPersonMatchesUser(
|
||||||
|
{ userId: damagedId, phoneNumber: "9123456789" },
|
||||||
|
{ sub: "other-user-id", username: "09123456789" },
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/helpers/blame-damaged-party.ts
Normal file
121
src/helpers/blame-damaged-party.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
|
import { partyPersonMatchesUser } from "./iran-mobile";
|
||||||
|
|
||||||
|
/** Resolve guilty party user id from blame case (expert decision → statement → AGREED first party). */
|
||||||
|
export function resolveGuiltyPartyUserId(blame: {
|
||||||
|
parties?: Array<{
|
||||||
|
role?: string;
|
||||||
|
person?: { userId?: unknown };
|
||||||
|
statement?: { admitsGuilt?: boolean };
|
||||||
|
}>;
|
||||||
|
expert?: { decision?: { guiltyPartyId?: unknown } };
|
||||||
|
blameStatus?: string;
|
||||||
|
}): string | null {
|
||||||
|
const parties = blame?.parties ?? [];
|
||||||
|
const decisionId = blame?.expert?.decision?.guiltyPartyId;
|
||||||
|
if (decisionId != null && String(decisionId)) {
|
||||||
|
return String(decisionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admitsGuilt = parties.find((p) => p.statement?.admitsGuilt === true);
|
||||||
|
if (admitsGuilt?.person?.userId != null) {
|
||||||
|
return String(admitsGuilt.person.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blame?.blameStatus === "AGREED") {
|
||||||
|
const first = parties.find((p) => p.role === "FIRST");
|
||||||
|
if (first?.person?.userId != null) {
|
||||||
|
return String(first.person.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */
|
||||||
|
export function resolveDamagedPartyPerson(blame: {
|
||||||
|
type?: string;
|
||||||
|
parties?: Array<{
|
||||||
|
role?: string;
|
||||||
|
person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown };
|
||||||
|
statement?: { admitsGuilt?: boolean; claimsDamage?: boolean };
|
||||||
|
}>;
|
||||||
|
expert?: { decision?: { guiltyPartyId?: unknown } };
|
||||||
|
blameStatus?: string;
|
||||||
|
}): { userId?: unknown; phoneNumber?: string; clientId?: unknown } | null {
|
||||||
|
const parties = blame?.parties ?? [];
|
||||||
|
if (!parties.length) return null;
|
||||||
|
|
||||||
|
const isCarBody =
|
||||||
|
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||||||
|
|
||||||
|
if (isCarBody) {
|
||||||
|
const first = parties.find((p) => p.role === "FIRST");
|
||||||
|
return first?.person ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guiltyId = resolveGuiltyPartyUserId(blame);
|
||||||
|
if (!guiltyId) {
|
||||||
|
const claimsDamage = parties.find((p) => p.statement?.claimsDamage === true);
|
||||||
|
return claimsDamage?.person ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const damagedParty = parties.find(
|
||||||
|
(p) =>
|
||||||
|
p.person?.userId != null && String(p.person.userId) !== String(guiltyId),
|
||||||
|
);
|
||||||
|
if (damagedParty?.person) return damagedParty.person;
|
||||||
|
|
||||||
|
const nonGuilty = parties.find(
|
||||||
|
(p) => p.person && String(p.person.userId ?? "") !== String(guiltyId),
|
||||||
|
);
|
||||||
|
return nonGuilty?.person ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blameDamagedPartyMatchesUser(
|
||||||
|
blame: Parameters<typeof resolveDamagedPartyPerson>[0],
|
||||||
|
user: { sub?: string; username?: string },
|
||||||
|
linkedUserIdStrings: string[] = [],
|
||||||
|
): boolean {
|
||||||
|
const person = resolveDamagedPartyPerson(blame);
|
||||||
|
if (!person) return false;
|
||||||
|
return partyPersonMatchesUser(person, user, linkedUserIdStrings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blame party that owns the claim row (`owner.userId` = guilty for THIRD_PARTY, first party for CAR_BODY). */
|
||||||
|
export function resolveClaimOwnerParty(blame: {
|
||||||
|
type?: string;
|
||||||
|
parties?: Array<{
|
||||||
|
role?: string;
|
||||||
|
person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown };
|
||||||
|
statement?: { admitsGuilt?: boolean };
|
||||||
|
}>;
|
||||||
|
expert?: { decision?: { guiltyPartyId?: unknown } };
|
||||||
|
blameStatus?: string;
|
||||||
|
}): {
|
||||||
|
role?: string;
|
||||||
|
person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown };
|
||||||
|
} | null {
|
||||||
|
const parties = blame?.parties ?? [];
|
||||||
|
const isCarBody =
|
||||||
|
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||||||
|
|
||||||
|
if (isCarBody) {
|
||||||
|
return parties.find((p) => p.role === "FIRST") ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guiltyId = resolveGuiltyPartyUserId(blame);
|
||||||
|
if (!guiltyId) return null;
|
||||||
|
return (
|
||||||
|
parties.find(
|
||||||
|
(p) => p.person?.userId != null && String(p.person.userId) === guiltyId,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDamagedPartyUserId(
|
||||||
|
blame: Parameters<typeof resolveDamagedPartyPerson>[0],
|
||||||
|
): string | null {
|
||||||
|
const person = resolveDamagedPartyPerson(blame);
|
||||||
|
return person?.userId != null ? String(person.userId) : null;
|
||||||
|
}
|
||||||
@@ -44,3 +44,32 @@ export function buildUserLookupByPhone(
|
|||||||
$or: variants.flatMap((v) => [{ username: v }, { mobile: v }]),
|
$or: variants.flatMap((v) => [{ username: v }, { mobile: v }]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when a blame party person row belongs to the authenticated user. */
|
||||||
|
export function partyPersonMatchesUser(
|
||||||
|
person:
|
||||||
|
| { userId?: unknown; phoneNumber?: string }
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
user: { sub?: string; username?: string } | null | undefined,
|
||||||
|
linkedUserIdStrings: string[] = [],
|
||||||
|
): boolean {
|
||||||
|
if (!person || !user) return false;
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
if (user.sub) userIds.add(String(user.sub));
|
||||||
|
for (const id of linkedUserIdStrings) {
|
||||||
|
if (id) userIds.add(String(id));
|
||||||
|
}
|
||||||
|
if (person.userId != null && userIds.has(String(person.userId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person.phoneNumber || !user.username) return false;
|
||||||
|
const stored =
|
||||||
|
normalizeIranMobile(person.phoneNumber) ?? person.phoneNumber;
|
||||||
|
const variants = iranMobileLookupVariants(user.username);
|
||||||
|
return variants.some(
|
||||||
|
(v) => v === stored || v === person.phoneNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
88
src/helpers/party-access-queries.spec.ts
Normal file
88
src/helpers/party-access-queries.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { partyPersonMatchesUser } from "./iran-mobile";
|
||||||
|
import {
|
||||||
|
buildBlamePartyAccessOrConditions,
|
||||||
|
mongoUserIdEqualityFilter,
|
||||||
|
} from "./party-access-queries";
|
||||||
|
|
||||||
|
describe("party access queries", () => {
|
||||||
|
const userId = new Types.ObjectId();
|
||||||
|
|
||||||
|
describe("mongoUserIdEqualityFilter", () => {
|
||||||
|
it("includes both ObjectId and string forms", () => {
|
||||||
|
const filter = mongoUserIdEqualityFilter(
|
||||||
|
"parties.person.userId",
|
||||||
|
String(userId),
|
||||||
|
);
|
||||||
|
expect(filter).toEqual({
|
||||||
|
$or: [
|
||||||
|
{ "parties.person.userId": String(userId) },
|
||||||
|
{ "parties.person.userId": userId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildBlamePartyAccessOrConditions", () => {
|
||||||
|
it("builds userId and phone branches", () => {
|
||||||
|
const or = buildBlamePartyAccessOrConditions({
|
||||||
|
sub: String(userId),
|
||||||
|
username: "09123456789",
|
||||||
|
});
|
||||||
|
expect(or).toHaveLength(2);
|
||||||
|
expect(or[0]).toHaveProperty("$or");
|
||||||
|
expect(or[1]).toEqual({
|
||||||
|
"parties.person.phoneNumber": {
|
||||||
|
$in: expect.arrayContaining(["09123456789", "9123456789"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes linked duplicate-account user ids", () => {
|
||||||
|
const linked = new Types.ObjectId();
|
||||||
|
const or = buildBlamePartyAccessOrConditions(
|
||||||
|
{ sub: String(userId), username: "09123456789" },
|
||||||
|
[String(linked)],
|
||||||
|
);
|
||||||
|
expect(or[0]).toEqual({
|
||||||
|
$or: expect.arrayContaining([
|
||||||
|
{ "parties.person.userId": String(userId) },
|
||||||
|
{ "parties.person.userId": userId },
|
||||||
|
{ "parties.person.userId": String(linked) },
|
||||||
|
{ "parties.person.userId": linked },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partyPersonMatchesUser", () => {
|
||||||
|
it("matches party userId stored as ObjectId against string JWT sub", () => {
|
||||||
|
expect(
|
||||||
|
partyPersonMatchesUser(
|
||||||
|
{ userId, phoneNumber: "09111111111" },
|
||||||
|
{ sub: String(userId), username: "09999999999" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches phone variants when userId is not set yet", () => {
|
||||||
|
expect(
|
||||||
|
partyPersonMatchesUser(
|
||||||
|
{ phoneNumber: "9123456789" },
|
||||||
|
{ sub: "other", username: "09123456789" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches linked duplicate-account user ids", () => {
|
||||||
|
const linked = new Types.ObjectId();
|
||||||
|
expect(
|
||||||
|
partyPersonMatchesUser(
|
||||||
|
{ userId: linked, phoneNumber: "09111111111" },
|
||||||
|
{ sub: String(userId), username: "09999999999" },
|
||||||
|
[String(linked)],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/helpers/party-access-queries.ts
Normal file
114
src/helpers/party-access-queries.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { iranMobileLookupVariants } from "./iran-mobile";
|
||||||
|
|
||||||
|
/** ObjectId + string variants for one or more user ids (legacy duplicate accounts). */
|
||||||
|
export function collectUserIdVariants(
|
||||||
|
...ids: (string | Types.ObjectId | undefined | null)[]
|
||||||
|
): unknown[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (id == null || id === "") continue;
|
||||||
|
const s = String(id);
|
||||||
|
if (seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
if (Types.ObjectId.isValid(s)) {
|
||||||
|
out.push(new Types.ObjectId(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserIdFieldOrFilter(
|
||||||
|
fieldPath: string,
|
||||||
|
idVariants: unknown[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!idVariants.length) return null;
|
||||||
|
if (idVariants.length === 1) {
|
||||||
|
return { [fieldPath]: idVariants[0] };
|
||||||
|
}
|
||||||
|
return { $or: idVariants.map((id) => ({ [fieldPath]: id })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mongo filter matching a user id field stored as ObjectId or legacy string.
|
||||||
|
*/
|
||||||
|
export function mongoUserIdEqualityFilter(
|
||||||
|
fieldPath: string,
|
||||||
|
userSub: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
return buildUserIdFieldOrFilter(fieldPath, collectUserIdVariants(userSub));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$or` branches: user is a party on a blame case (by userId and/or phone). */
|
||||||
|
export function buildBlamePartyAccessOrConditions(
|
||||||
|
user: {
|
||||||
|
sub?: string;
|
||||||
|
username?: string;
|
||||||
|
},
|
||||||
|
linkedUserIdStrings: string[] = [],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const or: Record<string, unknown>[] = [];
|
||||||
|
const idVariants = collectUserIdVariants(user.sub, ...linkedUserIdStrings);
|
||||||
|
const userIdCond = buildUserIdFieldOrFilter(
|
||||||
|
"parties.person.userId",
|
||||||
|
idVariants,
|
||||||
|
);
|
||||||
|
if (userIdCond) or.push(userIdCond);
|
||||||
|
|
||||||
|
const phoneVariants = user?.username
|
||||||
|
? iranMobileLookupVariants(user.username)
|
||||||
|
: [];
|
||||||
|
if (phoneVariants.length > 0) {
|
||||||
|
or.push({ "parties.person.phoneNumber": { $in: phoneVariants } });
|
||||||
|
}
|
||||||
|
return or;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$or` branches: user owns a claim or is tied via blame party membership. */
|
||||||
|
export function buildClaimOwnerAccessOrConditions(
|
||||||
|
userSub: string,
|
||||||
|
username?: string,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const or: Record<string, unknown>[] = [];
|
||||||
|
const ownerIdCond = mongoUserIdEqualityFilter("owner.userId", userSub);
|
||||||
|
if (ownerIdCond) or.push(ownerIdCond);
|
||||||
|
return or;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFieldExpertInitiatedBlameFilter(
|
||||||
|
expertSub: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const expertOid = Types.ObjectId.isValid(expertSub)
|
||||||
|
? new Types.ObjectId(expertSub)
|
||||||
|
: expertSub;
|
||||||
|
return {
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertOid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFieldExpertClaimAccessOrConditions(
|
||||||
|
expertSub: string,
|
||||||
|
expertBlameIds: unknown[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const expertOid = Types.ObjectId.isValid(expertSub)
|
||||||
|
? new Types.ObjectId(expertSub)
|
||||||
|
: expertSub;
|
||||||
|
const or: Record<string, unknown>[] = [
|
||||||
|
{ initiatedByFieldExpertId: expertOid },
|
||||||
|
];
|
||||||
|
if (expertBlameIds.length > 0) {
|
||||||
|
or.push({ blameRequestId: { $in: expertBlameIds } });
|
||||||
|
}
|
||||||
|
return or;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when actor is the field expert who initiated this blame (any JWT shape). */
|
||||||
|
export function actorIsFieldExpertInitiator(
|
||||||
|
actor: { sub?: string; role?: string } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return actor?.role === RoleEnum.FIELD_EXPERT && !!actor?.sub;
|
||||||
|
}
|
||||||
86
src/helpers/tenant-scope.spec.ts
Normal file
86
src/helpers/tenant-scope.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
blameCaseInitiatedByFieldExpert,
|
||||||
|
claimCaseInitiatedByFieldExpert,
|
||||||
|
} from "./tenant-scope";
|
||||||
|
|
||||||
|
describe("field expert ownership helpers", () => {
|
||||||
|
const expertId = new Types.ObjectId();
|
||||||
|
const otherExpertId = new Types.ObjectId();
|
||||||
|
const blameId = new Types.ObjectId();
|
||||||
|
|
||||||
|
describe("blameCaseInitiatedByFieldExpert", () => {
|
||||||
|
it("matches when initiatedByFieldExpertId equals actor.sub (string)", () => {
|
||||||
|
expect(
|
||||||
|
blameCaseInitiatedByFieldExpert(
|
||||||
|
{
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertId,
|
||||||
|
},
|
||||||
|
{ sub: String(expertId) },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require actor.role on JWT", () => {
|
||||||
|
expect(
|
||||||
|
blameCaseInitiatedByFieldExpert(
|
||||||
|
{
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertId,
|
||||||
|
},
|
||||||
|
{ sub: String(expertId) },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects another expert", () => {
|
||||||
|
expect(
|
||||||
|
blameCaseInitiatedByFieldExpert(
|
||||||
|
{
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertId,
|
||||||
|
},
|
||||||
|
{ sub: String(otherExpertId) },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("claimCaseInitiatedByFieldExpert", () => {
|
||||||
|
it("matches claim.initiatedByFieldExpertId", () => {
|
||||||
|
expect(
|
||||||
|
claimCaseInitiatedByFieldExpert(
|
||||||
|
{ initiatedByFieldExpertId: expertId },
|
||||||
|
{ sub: String(expertId) },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches via linked expert-initiated blame when claim field is missing", () => {
|
||||||
|
expect(
|
||||||
|
claimCaseInitiatedByFieldExpert(
|
||||||
|
{ blameRequestId: blameId },
|
||||||
|
{ sub: String(expertId) },
|
||||||
|
{
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: expertId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when neither claim nor blame links to actor", () => {
|
||||||
|
expect(
|
||||||
|
claimCaseInitiatedByFieldExpert(
|
||||||
|
{ blameRequestId: blameId },
|
||||||
|
{ sub: String(expertId) },
|
||||||
|
{
|
||||||
|
expertInitiated: true,
|
||||||
|
initiatedByFieldExpertId: otherExpertId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
|
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
|
||||||
export function requireActorClientKey(actor: { clientKey?: string }): string {
|
export function requireActorClientKey(actor: { clientKey?: string }): string {
|
||||||
@@ -64,6 +65,18 @@ export function blameCaseAccessibleToExpert(
|
|||||||
return blameCaseTouchesClient(doc, ck);
|
return blameCaseTouchesClient(doc, ck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when `actor` is the field expert who created this expert-initiated blame file. */
|
||||||
|
export function blameCaseInitiatedByFieldExpert(
|
||||||
|
doc: any,
|
||||||
|
actor: { sub: string },
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!!doc?.expertInitiated &&
|
||||||
|
!!doc?.initiatedByFieldExpertId &&
|
||||||
|
String(doc.initiatedByFieldExpertId) === String(actor.sub)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function assertBlameCaseForExpertTenant(
|
export function assertBlameCaseForExpertTenant(
|
||||||
doc: any,
|
doc: any,
|
||||||
actor: { sub: string; clientKey?: string },
|
actor: { sub: string; clientKey?: string },
|
||||||
@@ -88,28 +101,40 @@ export function assertClaimCaseForTenant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when a field expert (no clientKey) initiated this claim via an
|
* True when the field expert initiated this claim directly or via the linked
|
||||||
* expert-initiated IN_PERSON blame file.
|
* expert-initiated blame file (`blameRequestId` → blame.initiatedByFieldExpertId).
|
||||||
*/
|
*/
|
||||||
export function claimCaseInitiatedByFieldExpert(
|
export function claimCaseInitiatedByFieldExpert(
|
||||||
doc: any,
|
doc: any,
|
||||||
actor: { sub: string },
|
actor: { sub: string },
|
||||||
|
blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
if (
|
||||||
!!doc.initiatedByFieldExpertId &&
|
doc?.initiatedByFieldExpertId &&
|
||||||
String(doc.initiatedByFieldExpertId) === String(actor.sub)
|
String(doc.initiatedByFieldExpertId) === String(actor.sub)
|
||||||
);
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
blame?.expertInitiated &&
|
||||||
|
blame?.initiatedByFieldExpertId &&
|
||||||
|
String(blame.initiatedByFieldExpertId) === String(actor.sub)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorization check that works for both damage experts (clientKey tenant
|
* Authorization check that works for both damage experts (clientKey tenant
|
||||||
* scope) and field experts (initiatedByFieldExpertId ownership).
|
* scope) and field experts (initiatedByFieldExpertId / linked blame ownership).
|
||||||
*/
|
*/
|
||||||
export function assertClaimCaseForExpertActor(
|
export function assertClaimCaseForExpertActor(
|
||||||
doc: any,
|
doc: any,
|
||||||
actor: { sub: string; clientKey?: string },
|
actor: { sub: string; clientKey?: string },
|
||||||
|
blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null,
|
||||||
): void {
|
): void {
|
||||||
if (claimCaseInitiatedByFieldExpert(doc, actor)) return;
|
if (claimCaseInitiatedByFieldExpert(doc, actor, blame)) return;
|
||||||
assertClaimCaseForTenant(doc, actor);
|
assertClaimCaseForTenant(doc, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
src/helpers/user-access-resolver.ts
Normal file
52
src/helpers/user-access-resolver.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { buildUserLookupByPhone } from "./iran-mobile";
|
||||||
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
import { collectUserIdVariants } from "./party-access-queries";
|
||||||
|
|
||||||
|
/** All Mongo user ids that may represent the same person (JWT sub + phone aliases). */
|
||||||
|
export async function resolveLinkedUserIdStrings(
|
||||||
|
userDbService: UserDbService,
|
||||||
|
actor: { sub?: string; username?: string },
|
||||||
|
): Promise<string[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
if (actor?.sub) ids.add(String(actor.sub));
|
||||||
|
|
||||||
|
if (actor?.username?.trim()) {
|
||||||
|
const rows = await userDbService.findAllByPhone(actor.username);
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = (row as { _id?: unknown })._id;
|
||||||
|
if (id != null) ids.add(String(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveLinkedUserIdVariants(
|
||||||
|
userDbService: UserDbService,
|
||||||
|
actor: { sub?: string; username?: string },
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const strings = await resolveLinkedUserIdStrings(userDbService, actor);
|
||||||
|
return collectUserIdVariants(...strings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerUserIdMatchesActor(
|
||||||
|
ownerUserId: unknown,
|
||||||
|
linkedUserIdStrings: string[],
|
||||||
|
): boolean {
|
||||||
|
if (ownerUserId == null || linkedUserIdStrings.length === 0) return false;
|
||||||
|
const owner = String(ownerUserId);
|
||||||
|
return linkedUserIdStrings.some((id) => id === owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOwnerUserIdAccessFilter(
|
||||||
|
linkedUserIdVariants: unknown[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!linkedUserIdVariants.length) return null;
|
||||||
|
if (linkedUserIdVariants.length === 1) {
|
||||||
|
return { "owner.userId": linkedUserIdVariants[0] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
$or: linkedUserIdVariants.map((id) => ({ "owner.userId": id })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -91,6 +91,17 @@ import {
|
|||||||
MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
|
MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
|
||||||
} from "src/helpers/blame-party-agreement-decision";
|
} from "src/helpers/blame-party-agreement-decision";
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
import { buildFileLink } from "src/helpers/urlCreator";
|
||||||
|
import {
|
||||||
|
buildUserLookupByPhone,
|
||||||
|
iranMobileLookupVariants,
|
||||||
|
normalizeIranMobile,
|
||||||
|
partyPersonMatchesUser,
|
||||||
|
} from "src/helpers/iran-mobile";
|
||||||
|
import {
|
||||||
|
buildBlamePartyAccessOrConditions,
|
||||||
|
collectUserIdVariants,
|
||||||
|
} from "src/helpers/party-access-queries";
|
||||||
|
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RequestManagementService {
|
export class RequestManagementService {
|
||||||
@@ -209,14 +220,8 @@ export class RequestManagementService {
|
|||||||
private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) {
|
private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) {
|
||||||
// The initiating field-expert/registrar fills steps on behalf of parties.
|
// The initiating field-expert/registrar fills steps on behalf of parties.
|
||||||
if (req && this.isBlameOnBehalfActor(req, user)) return;
|
if (req && this.isBlameOnBehalfActor(req, user)) return;
|
||||||
const partyUserId = party?.person?.userId
|
if (partyPersonMatchesUser(party?.person, user)) return;
|
||||||
? String(party.person.userId)
|
throw new ForbiddenException(errMsg);
|
||||||
: null;
|
|
||||||
const ok =
|
|
||||||
(partyUserId && partyUserId === String(user?.sub)) ||
|
|
||||||
(party?.person?.phoneNumber &&
|
|
||||||
party.person.phoneNumber === user?.username);
|
|
||||||
if (!ok) throw new ForbiddenException(errMsg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4434,7 +4439,8 @@ export class RequestManagementService {
|
|||||||
throw new BadRequestException("First party not found on request");
|
throw new BadRequestException("First party not found on request");
|
||||||
const userId = await this.getOrCreateUserByPhoneNumber(phone);
|
const userId = await this.getOrCreateUserByPhoneNumber(phone);
|
||||||
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||||||
req.parties[firstIdx].person.phoneNumber = phone;
|
req.parties[firstIdx].person.phoneNumber =
|
||||||
|
normalizeIranMobile(phone) ?? phone;
|
||||||
req.parties[firstIdx].person.userId = userId;
|
req.parties[firstIdx].person.userId = userId;
|
||||||
|
|
||||||
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
|
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
|
||||||
@@ -4560,9 +4566,9 @@ export class RequestManagementService {
|
|||||||
phone: string,
|
phone: string,
|
||||||
otp: string,
|
otp: string,
|
||||||
): Promise<Types.ObjectId> => {
|
): Promise<Types.ObjectId> => {
|
||||||
const user = await this.userDbService.findOne({
|
const user = await this.userDbService.findOne(
|
||||||
$or: [{ username: phone }, { mobile: phone }],
|
buildUserLookupByPhone(phone),
|
||||||
});
|
);
|
||||||
if (!user)
|
if (!user)
|
||||||
throw new BadRequestException(`User not found for phone: ${phone}`);
|
throw new BadRequestException(`User not found for phone: ${phone}`);
|
||||||
const u = user as any;
|
const u = user as any;
|
||||||
@@ -4591,7 +4597,9 @@ export class RequestManagementService {
|
|||||||
throw new BadRequestException("First party not found on request");
|
throw new BadRequestException("First party not found on request");
|
||||||
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||||||
req.parties[firstIdx].person.userId = firstUserId;
|
req.parties[firstIdx].person.userId = firstUserId;
|
||||||
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber;
|
req.parties[firstIdx].person.phoneNumber =
|
||||||
|
normalizeIranMobile(dto.firstPartyPhoneNumber) ??
|
||||||
|
dto.firstPartyPhoneNumber;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.type === BlameRequestType.THIRD_PARTY &&
|
req.type === BlameRequestType.THIRD_PARTY &&
|
||||||
@@ -4617,7 +4625,9 @@ export class RequestManagementService {
|
|||||||
if (!req.parties[secondIdx].person)
|
if (!req.parties[secondIdx].person)
|
||||||
req.parties[secondIdx].person = {} as any;
|
req.parties[secondIdx].person = {} as any;
|
||||||
req.parties[secondIdx].person.userId = secondUserId;
|
req.parties[secondIdx].person.userId = secondUserId;
|
||||||
req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber;
|
req.parties[secondIdx].person.phoneNumber =
|
||||||
|
normalizeIranMobile(dto.secondPartyPhoneNumber) ??
|
||||||
|
dto.secondPartyPhoneNumber;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
req.type === BlameRequestType.THIRD_PARTY &&
|
req.type === BlameRequestType.THIRD_PARTY &&
|
||||||
@@ -4777,9 +4787,9 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
// Verify the OTP and resolve the user id.
|
// Verify the OTP and resolve the user id.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const user = await this.userDbService.findOne({
|
const user = await this.userDbService.findOne(
|
||||||
$or: [{ username: phone }, { mobile: phone }],
|
buildUserLookupByPhone(phone),
|
||||||
});
|
);
|
||||||
if (!user)
|
if (!user)
|
||||||
throw new BadRequestException(`User not found for phone: ${phone}`);
|
throw new BadRequestException(`User not found for phone: ${phone}`);
|
||||||
const u = user as any;
|
const u = user as any;
|
||||||
@@ -4794,6 +4804,7 @@ export class RequestManagementService {
|
|||||||
const valid = await this.hashService.compare(otp, u.otp);
|
const valid = await this.hashService.compare(otp, u.otp);
|
||||||
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
|
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
|
||||||
const userId = u._id as Types.ObjectId;
|
const userId = u._id as Types.ObjectId;
|
||||||
|
const canonicalPhone = normalizeIranMobile(phone) ?? phone;
|
||||||
|
|
||||||
if (role === PartyRole.FIRST) {
|
if (role === PartyRole.FIRST) {
|
||||||
if (firstIdx === -1)
|
if (firstIdx === -1)
|
||||||
@@ -4801,13 +4812,21 @@ export class RequestManagementService {
|
|||||||
if (!req.parties[firstIdx].person)
|
if (!req.parties[firstIdx].person)
|
||||||
req.parties[firstIdx].person = {} as any;
|
req.parties[firstIdx].person = {} as any;
|
||||||
req.parties[firstIdx].person.userId = userId;
|
req.parties[firstIdx].person.userId = userId;
|
||||||
req.parties[firstIdx].person.phoneNumber = phone;
|
req.parties[firstIdx].person.phoneNumber = canonicalPhone;
|
||||||
} else {
|
} else {
|
||||||
const firstPhone =
|
const firstPhone =
|
||||||
firstIdx !== -1
|
firstIdx !== -1
|
||||||
? (req.parties[firstIdx]?.person as any)?.phoneNumber
|
? (req.parties[firstIdx]?.person as any)?.phoneNumber
|
||||||
: undefined;
|
: undefined;
|
||||||
if (firstPhone && firstPhone === phone) {
|
const firstVariants = firstPhone
|
||||||
|
? iranMobileLookupVariants(firstPhone)
|
||||||
|
: [];
|
||||||
|
if (
|
||||||
|
firstPhone &&
|
||||||
|
(firstPhone === phone ||
|
||||||
|
firstVariants.includes(phone) ||
|
||||||
|
firstVariants.includes(canonicalPhone))
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Second party phone number cannot be the same as first party.",
|
"Second party phone number cannot be the same as first party.",
|
||||||
);
|
);
|
||||||
@@ -4816,13 +4835,13 @@ export class RequestManagementService {
|
|||||||
if (secondIdx === -1) {
|
if (secondIdx === -1) {
|
||||||
req.parties.push({
|
req.parties.push({
|
||||||
role: PartyRole.SECOND,
|
role: PartyRole.SECOND,
|
||||||
person: { userId, phoneNumber: phone },
|
person: { userId, phoneNumber: canonicalPhone },
|
||||||
} as any);
|
} as any);
|
||||||
} else {
|
} else {
|
||||||
if (!req.parties[secondIdx].person)
|
if (!req.parties[secondIdx].person)
|
||||||
req.parties[secondIdx].person = {} as any;
|
req.parties[secondIdx].person = {} as any;
|
||||||
req.parties[secondIdx].person.userId = userId;
|
req.parties[secondIdx].person.userId = userId;
|
||||||
req.parties[secondIdx].person.phoneNumber = phone;
|
req.parties[secondIdx].person.phoneNumber = canonicalPhone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5014,17 +5033,14 @@ export class RequestManagementService {
|
|||||||
query: ListQueryV2Dto = {},
|
query: ListQueryV2Dto = {},
|
||||||
): Promise<GetUserBlameListV2ResponseDto> {
|
): Promise<GetUserBlameListV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
const userIdFilter =
|
const linkedUserIds =
|
||||||
user?.sub && Types.ObjectId.isValid(user.sub)
|
user?.role === RoleEnum.USER || !user?.role
|
||||||
? { "parties.person.userId": new Types.ObjectId(user.sub) }
|
? await resolveLinkedUserIdStrings(this.userDbService, user)
|
||||||
: null;
|
: [];
|
||||||
const phoneFilter = user?.username
|
const orConditions = buildBlamePartyAccessOrConditions(
|
||||||
? { "parties.person.phoneNumber": user.username }
|
user,
|
||||||
: null;
|
linkedUserIds,
|
||||||
|
);
|
||||||
const orConditions: Record<string, unknown>[] = [];
|
|
||||||
if (userIdFilter) orConditions.push(userIdFilter);
|
|
||||||
if (phoneFilter) orConditions.push(phoneFilter);
|
|
||||||
|
|
||||||
if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) {
|
if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) {
|
||||||
orConditions.push({
|
orConditions.push({
|
||||||
@@ -5038,6 +5054,34 @@ export class RequestManagementService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot-party fallback: find blames via claims where this user appears
|
||||||
|
// in claim.snapshot.parties (the most reliable path for IN_PERSON flows).
|
||||||
|
const idVariants = collectUserIdVariants(
|
||||||
|
...(linkedUserIds.length > 0 ? linkedUserIds : [user?.sub].filter(Boolean)),
|
||||||
|
);
|
||||||
|
if (idVariants.length > 0) {
|
||||||
|
const snapshotFilter =
|
||||||
|
idVariants.length === 1
|
||||||
|
? { "snapshot.parties.person.userId": idVariants[0] }
|
||||||
|
: { "snapshot.parties.person.userId": { $in: idVariants } };
|
||||||
|
const linkedClaims = (await this.claimCaseDbService.find(
|
||||||
|
snapshotFilter as any,
|
||||||
|
{ lean: true, select: "blameRequestId owner.userId" },
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
for (const c of linkedClaims) {
|
||||||
|
if (!c.blameRequestId) continue;
|
||||||
|
// Exclude if this user IS the owner (guilty party) — they already show
|
||||||
|
// up via the claim-owner route; we only want the damaged party here.
|
||||||
|
const ownerStr = c.owner?.userId ? String(c.owner.userId) : null;
|
||||||
|
const isOwner = ownerStr
|
||||||
|
? idVariants.some((id) => String(id) === ownerStr)
|
||||||
|
: false;
|
||||||
|
if (isOwner) continue;
|
||||||
|
orConditions.push({ _id: c.blameRequestId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (orConditions.length === 0) {
|
if (orConditions.length === 0) {
|
||||||
return { list: [], total: 0 };
|
return { list: [], total: 0 };
|
||||||
}
|
}
|
||||||
@@ -5061,11 +5105,8 @@ export class RequestManagementService {
|
|||||||
req.registrarInitiated &&
|
req.registrarInitiated &&
|
||||||
String(req.initiatedByRegistrarId) === String(user.sub));
|
String(req.initiatedByRegistrarId) === String(user.sub));
|
||||||
|
|
||||||
const party = req.parties?.find(
|
const party = req.parties?.find((p: any) =>
|
||||||
(p: any) =>
|
partyPersonMatchesUser(p?.person, user, linkedUserIds),
|
||||||
(p?.person?.userId &&
|
|
||||||
String(p.person.userId) === String(user.sub)) ||
|
|
||||||
(p?.person?.phoneNumber && p.person.phoneNumber === user?.username),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const obj = req.toObject();
|
const obj = req.toObject();
|
||||||
@@ -5330,6 +5371,10 @@ export class RequestManagementService {
|
|||||||
if (!req) {
|
if (!req) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
const linkedUserIds =
|
||||||
|
user?.role === RoleEnum.USER || !user?.role
|
||||||
|
? await resolveLinkedUserIdStrings(this.userDbService, user)
|
||||||
|
: [];
|
||||||
const isFieldExpertOwner =
|
const isFieldExpertOwner =
|
||||||
req.expertInitiated &&
|
req.expertInitiated &&
|
||||||
req.initiatedByFieldExpertId &&
|
req.initiatedByFieldExpertId &&
|
||||||
@@ -5340,14 +5385,9 @@ export class RequestManagementService {
|
|||||||
String(req.initiatedByRegistrarId) === String(user?.sub);
|
String(req.initiatedByRegistrarId) === String(user?.sub);
|
||||||
const isParty =
|
const isParty =
|
||||||
Array.isArray(req.parties) &&
|
Array.isArray(req.parties) &&
|
||||||
req.parties.some((p: any) => {
|
req.parties.some((p: any) =>
|
||||||
const pid = p?.person?.userId ? String(p.person.userId) : null;
|
partyPersonMatchesUser(p?.person, user, linkedUserIds),
|
||||||
const phone = p?.person?.phoneNumber;
|
|
||||||
return (
|
|
||||||
(pid && pid === String(user?.sub)) ||
|
|
||||||
(phone && phone === user?.username)
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) {
|
if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) {
|
||||||
throw new ForbiddenException("You do not have access to this request");
|
throw new ForbiddenException("You do not have access to this request");
|
||||||
}
|
}
|
||||||
@@ -5359,10 +5399,22 @@ export class RequestManagementService {
|
|||||||
Types.ObjectId.isValid(user.sub)
|
Types.ObjectId.isValid(user.sub)
|
||||||
) {
|
) {
|
||||||
let updated = false;
|
let updated = false;
|
||||||
|
const phoneVariants = iranMobileLookupVariants(user?.username);
|
||||||
for (const p of req.parties || []) {
|
for (const p of req.parties || []) {
|
||||||
if (p?.person?.phoneNumber === user?.username && !p.person?.userId) {
|
const person = p?.person;
|
||||||
|
if (!person) continue;
|
||||||
|
const phoneMatch =
|
||||||
|
person.phoneNumber &&
|
||||||
|
phoneVariants.some(
|
||||||
|
(v) => v === person.phoneNumber || v === normalizeIranMobile(person.phoneNumber),
|
||||||
|
);
|
||||||
|
if (phoneMatch && !person?.userId) {
|
||||||
p.person = p.person || {};
|
p.person = p.person || {};
|
||||||
(p.person as any).userId = new Types.ObjectId(user.sub);
|
(p.person as any).userId = new Types.ObjectId(user.sub);
|
||||||
|
if (user?.username) {
|
||||||
|
(p.person as any).phoneNumber =
|
||||||
|
normalizeIranMobile(user.username) ?? user.username;
|
||||||
|
}
|
||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5384,14 +5436,9 @@ export class RequestManagementService {
|
|||||||
const visibleParties =
|
const visibleParties =
|
||||||
isFieldExpertOwner || isRegistrarOwner
|
isFieldExpertOwner || isRegistrarOwner
|
||||||
? allParties
|
? allParties
|
||||||
: allParties.filter((p: any) => {
|
: allParties.filter((p: any) =>
|
||||||
const pid = p?.person?.userId ? String(p.person.userId) : null;
|
partyPersonMatchesUser(p?.person, user, linkedUserIds),
|
||||||
const phone = p?.person?.phoneNumber;
|
|
||||||
return (
|
|
||||||
(pid && pid === String(user?.sub)) ||
|
|
||||||
(phone && phone === user?.username)
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
const parties = await Promise.all(
|
const parties = await Promise.all(
|
||||||
visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)),
|
visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)),
|
||||||
);
|
);
|
||||||
@@ -5584,7 +5631,8 @@ export class RequestManagementService {
|
|||||||
role: PartyRole.FIRST,
|
role: PartyRole.FIRST,
|
||||||
person: {
|
person: {
|
||||||
userId: firstPartyUserId,
|
userId: firstPartyUserId,
|
||||||
phoneNumber: formData.firstPartyPhoneNumber,
|
phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ??
|
||||||
|
formData.firstPartyPhoneNumber,
|
||||||
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
|
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
|
||||||
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
||||||
insurerLicense: firstPartyPlate.insurerLicense,
|
insurerLicense: firstPartyPlate.insurerLicense,
|
||||||
@@ -5769,7 +5817,7 @@ export class RequestManagementService {
|
|||||||
role,
|
role,
|
||||||
person: {
|
person: {
|
||||||
userId,
|
userId,
|
||||||
phoneNumber,
|
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
|
||||||
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
||||||
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
||||||
insurerLicense: plateDto.insurerLicense,
|
insurerLicense: plateDto.insurerLicense,
|
||||||
@@ -7288,19 +7336,18 @@ export class RequestManagementService {
|
|||||||
private async getOrCreateUserByPhoneNumber(
|
private async getOrCreateUserByPhoneNumber(
|
||||||
phoneNumber: string,
|
phoneNumber: string,
|
||||||
): Promise<Types.ObjectId> {
|
): Promise<Types.ObjectId> {
|
||||||
// Try to find existing user by phone number (username or mobile)
|
const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim();
|
||||||
let user = await this.userDbService.findOne({
|
let user = await this.userDbService.findOne(
|
||||||
$or: [{ username: phoneNumber }, { mobile: phoneNumber }],
|
buildUserLookupByPhone(canonical),
|
||||||
});
|
);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return new Types.ObjectId((user as any)._id);
|
return new Types.ObjectId((user as any)._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// User doesn't exist, create a new one
|
|
||||||
const newUser = await this.userDbService.createUser({
|
const newUser = await this.userDbService.createUser({
|
||||||
mobile: phoneNumber,
|
mobile: canonical,
|
||||||
username: phoneNumber,
|
username: canonical,
|
||||||
otp: "",
|
otp: "",
|
||||||
tokens: {
|
tokens: {
|
||||||
token: "",
|
token: "",
|
||||||
|
|||||||
@@ -449,11 +449,16 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = raw?.data ?? {};
|
// Real ESG response wraps payload in { success: true, data: { ... } }.
|
||||||
|
// Mock data and Tejarat-format fallback are flat objects with no .data wrapper —
|
||||||
|
// in that case use raw itself so CompanyName / CompanyCode are preserved.
|
||||||
|
const data =
|
||||||
|
raw?.data != null && typeof raw?.data === "object" ? raw.data : raw;
|
||||||
return this.mapNewApiResponseToOldFormat({
|
return this.mapNewApiResponseToOldFormat({
|
||||||
...data,
|
...data,
|
||||||
companyId: data.CmpCod ?? data.companyId,
|
companyId: data.CmpCod ?? data.companyId ?? data.CompanyCode,
|
||||||
companyPersianName: data.CmpNam ?? data.companyPersianName,
|
companyPersianName:
|
||||||
|
data.CmpNam ?? data.companyPersianName ?? data.CompanyName,
|
||||||
carGrpCod: data.CarGrpCod ?? data.carGrpCod,
|
carGrpCod: data.CarGrpCod ?? data.carGrpCod,
|
||||||
usgCod: data.UsgCod ?? data.usgCod,
|
usgCod: data.UsgCod ?? data.usgCod,
|
||||||
vehSysCod: data.VehSysCod ?? data.vehSysCod,
|
vehSysCod: data.VehSysCod ?? data.vehSysCod,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
||||||
|
import { buildUserLookupByPhone } from "src/helpers/iran-mobile";
|
||||||
import { UserDocument, UserModel } from "../schema/user.schema";
|
import { UserDocument, UserModel } from "../schema/user.schema";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -17,6 +18,12 @@ export class UserDbService {
|
|||||||
return await this.userModel.findOne(user);
|
return await this.userModel.findOne(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllByPhone(phone: string): Promise<UserModel[]> {
|
||||||
|
return (await this.userModel
|
||||||
|
.find(buildUserLookupByPhone(phone))
|
||||||
|
.lean()) as UserModel[];
|
||||||
|
}
|
||||||
|
|
||||||
async findPlate(plate: number) {
|
async findPlate(plate: number) {
|
||||||
return await this.userModel.find({ "plates.leftDigits": { $in: plate } });
|
return await this.userModel.find({ "plates.leftDigits": { $in: plate } });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user