Compare commits

...

154 Commits

Author SHA1 Message Date
07c4e5126a Merge pull request 'Fixed bugs' (#64) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#64
2026-05-10 17:01:23 +03:30
SepehrYahyaee
010846acd9 Fixed bugs 2026-05-10 17:00:41 +03:30
SepehrYahyaee
cc926d4668 deprecated some old APIs + added examples for login 2026-05-10 14:29:32 +03:30
fdb75d52f7 Merge pull request 'YARA-884' (#63) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#63
2026-05-10 14:16:05 +03:30
SepehrYahyaee
186f6c5837 YARA-884 2026-05-10 14:15:38 +03:30
SepehrYahyaee
3fb90cf1c9 YARA-886 2026-05-10 14:04:11 +03:30
5e5ad0e1b3 Merge pull request 'Added sign links and data to expert-claim API' (#62) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#62
2026-05-10 12:42:37 +03:30
SepehrYahyaee
82bb232d28 Added sign links and data to expert-claim API 2026-05-10 12:42:24 +03:30
45392ad268 Merge pull request 'YARA-877' (#61) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#61
2026-05-10 11:44:07 +03:30
SepehrYahyaee
9c62dc4d3a YARA-877 2026-05-10 11:43:28 +03:30
d1bc64bb6d Merge pull request 'Fixed sheba inquiry' (#60) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#60
2026-05-09 17:51:58 +03:30
SepehrYahyaee
be58b7e47e Fixed sheba inquiry 2026-05-09 17:51:31 +03:30
78e86e5745 Merge pull request 'Fixed external API call to personal inquiry' (#59) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#59
2026-05-09 16:17:57 +03:30
SepehrYahyaee
fe163419c0 Fixed personal inquiry 2026-05-09 16:17:28 +03:30
SepehrYahyaee
eb648d8a87 Fixed personal inquiry 2026-05-09 16:17:09 +03:30
b856cb59f9 Merge pull request 'YARA-732' (#58) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#58
2026-05-09 14:00:40 +03:30
SepehrYahyaee
a52b7a0a72 Added catalog endpoints to claim expert panel 2026-05-09 14:00:02 +03:30
SepehrYahyaee
7998649a89 Added external APIs inquiries 2026-05-09 13:48:19 +03:30
SepehrYahyaee
74c91c73b6 Moved OTP to SMS module 2026-05-09 12:36:38 +03:30
SepehrYahyaee
9e2cec5bc3 YARA-732 2026-05-09 12:09:17 +03:30
e95cb4c255 Merge pull request 'Added extra fields for insurer' (#57) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#57
2026-05-09 10:14:27 +03:30
SepehrYahyaee
c1a54baaf0 Added extra fields for insurer 2026-05-09 09:59:01 +03:30
f8193b6622 Merge pull request 'main' (#56) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#56
2026-05-05 14:53:58 +03:30
3e889307a1 merge upstream 2026-05-05 10:13:11 +03:30
Soheil Hajizadeh
972b4b8719 status modfied 2026-05-04 21:19:11 +03:30
96c21294db Merge pull request 'Fixed claim/blame damagedParts' (#55) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#55
2026-05-03 13:56:41 +03:30
c579f8fa1d Fixed claim/blame damagedParts 2026-05-03 13:54:48 +03:30
c2d59112cf Merge pull request 'user owner guidence added + status and steps fixed' (#54) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#54
2026-05-02 01:54:00 +03:30
Soheil Hajizadeh
e1115b0632 user owner guidence added + status and steps fixed 2026-05-02 01:50:45 +03:30
908292b0c3 Merge pull request 'Fix steps' (#53) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#53
2026-05-01 18:16:50 +03:30
55ec6f1fd1 Fix steps 2026-05-01 17:52:25 +03:30
d33cff8438 Merge pull request 'Fix workflow steps' (#52) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#52
2026-05-01 16:01:53 +03:30
0bb13f4596 Fix workflow steps 2026-05-01 16:01:08 +03:30
70306d42e0 Merge pull request 'Fix damaged-parts flow bug on carAngles' (#51) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#51
2026-05-01 14:45:39 +03:30
d9dc4ecdff Fix damaged-parts flow bug on carAngles 2026-05-01 14:44:28 +03:30
f66fa5d7b4 Merge pull request 'YARA-867, YARA-868' (#50) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#50
2026-05-01 11:38:50 +03:30
6429cb0f2b Fix bugs 2026-05-01 11:37:24 +03:30
6f120b0066 YARA-868 2026-05-01 11:25:10 +03:30
8419ec06ae YARA-867 2026-05-01 11:14:05 +03:30
def4023185 Merge pull request 'Fix data returning owner on claimDetails' (#49) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#49
2026-04-30 13:31:28 +03:30
40606fecf1 Fix data returning owner on claimDetails 2026-04-30 13:30:58 +03:30
c567f93e85 Merge pull request 'Added sign endpoint for claim' (#48) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#48
2026-04-30 13:18:04 +03:30
a9846095c1 Added sign endpoint for claim 2026-04-30 13:17:23 +03:30
b6f0e3c821 Merge pull request 'YARA-855, YARA-856' (#47) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#47
2026-04-30 10:34:52 +03:30
bffb8a3b97 YARA-855 2026-04-30 10:32:53 +03:30
715a9f2467 YARA-856 2026-04-30 09:57:28 +03:30
3813c2b3e3 Merge pull request 'YARA-854, YARA-848, YARA-850' (#46) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#46
2026-04-29 20:24:00 +03:30
ebf8a9a624 YARA-850 2026-04-29 20:22:43 +03:30
993d809de2 YARA-854 2026-04-29 14:26:04 +03:30
80ef885d3b Merge pull request 'Fixed label_fa' (#45) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#45
2026-04-28 16:33:36 +03:30
SepehrYahyaee
3f608f63f1 Fixed label_fa 2026-04-28 16:32:27 +03:30
6019c9e954 Merge pull request 'YARA-853, YARA-857, YARA-858 and a couple of fixes' (#44) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#44
2026-04-28 15:40:31 +03:30
SepehrYahyaee
8ae6f2c91b Fix get details for insurer 2026-04-28 15:39:06 +03:30
SepehrYahyaee
f999313476 YARA-853 2026-04-28 15:22:04 +03:30
SepehrYahyaee
98f1d2caf5 YARA-857 2026-04-28 14:41:47 +03:30
SepehrYahyaee
bbd83da2d5 YARA-858 2026-04-28 14:27:12 +03:30
SepehrYahyaee
9296795166 Added factorLink and branchName support 2026-04-28 13:31:02 +03:30
SepehrYahyaee
f456443342 Fixed blame resend 2026-04-28 10:15:03 +03:30
SepehrYahyaee
bcedd8c6f3 Added GET car-other-parts in v2 as well; deprecated old endpoints and reordered swagger documents 2026-04-28 10:01:45 +03:30
8caf13cf18 Merge pull request 'YARA-833' (#43) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#43
2026-04-27 17:04:18 +03:30
SepehrYahyaee
e90f8dc33c YARA-833 2026-04-27 17:03:29 +03:30
4c4b1a1db7 Merge pull request 'Fixed legacy requestedCounts methods and statistics + claimLink address' (#42) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#42
2026-04-27 15:30:21 +03:30
SepehrYahyaee
c2f996cc28 Fixed legacy requestedCounts methods and statistics + claimLink address 2026-04-27 15:29:44 +03:30
362e02ddc4 Merge pull request 'Fix locks' (#41) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#41
2026-04-26 16:09:17 +03:30
SepehrYahyaee
4b0e8a3547 FIX LOCKS 2026-04-26 16:08:14 +03:30
SepehrYahyaee
885678df7d Fixed lock mechanism, and some resendItem types 2026-04-26 15:58:21 +03:30
8dffc46357 Merge pull request 'YARA-725, YARA-830, YARA-832' (#40) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#40
2026-04-26 11:44:54 +03:30
SepehrYahyaee
b5b3b722c6 YARA-725 2026-04-26 11:43:17 +03:30
SepehrYahyaee
4f8cb43883 YARA-832 2026-04-26 09:31:50 +03:30
SepehrYahyaee
6c2d178686 YARA-830 2026-04-26 09:16:58 +03:30
9854a58282 Merge pull request 'Fix' (#39) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#39
2026-04-25 17:38:23 +03:30
SepehrYahyaee
7184142137 Fix 2026-04-25 17:38:01 +03:30
d45975b6a7 Merge pull request 'YARA-837, YARA-836' (#38) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#38
2026-04-25 17:12:59 +03:30
SepehrYahyaee
05c5b70b4d YARA-837 2026-04-25 17:10:07 +03:30
SepehrYahyaee
5d2227b00b YARA-836 + 2 more sms 2026-04-25 16:53:56 +03:30
eef305c42e Merge pull request 'Added required documents for blame, added 2 sms' (#37) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#37
2026-04-25 15:15:11 +03:30
SepehrYahyaee
9c87927e6c Added required documents for blame, added 2 sms 2026-04-25 15:14:44 +03:30
5618d120e3 Merge pull request 'YARA-834' (#36) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#36
2026-04-22 15:03:28 +03:30
SepehrYahyaee
d9b1537ee4 Centralized SMS services YARA-834 2026-04-22 15:02:08 +03:30
f01882dc3a Merge pull request 'Added SMS for Link flow of FIELD-EXPERT: YARA-802' (#35) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#35
2026-04-22 11:14:08 +03:30
SepehrYahyaee
8284aba825 Added SMS for Link flow of FIELD-EXPERT: YARA-802 2026-04-22 11:13:47 +03:30
3c15901ecc Merge pull request 'Fixing createdAtFa' (#34) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#34
2026-04-22 10:35:39 +03:30
SepehrYahyaee
1e1edf9136 Fixing createdAtFa 2026-04-22 10:35:00 +03:30
daad9539ea Merge pull request 'YARA-821' (#33) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#33
2026-04-22 10:30:05 +03:30
SepehrYahyaee
194b71cdb2 YARA-821 2026-04-22 10:29:01 +03:30
SepehrYahyaee
04ba46ed2a Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-04-22 09:11:42 +03:30
c6f7edabd2 Merge pull request 'daghi problem fixed , 2 apis for reporting counts added' (#32) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#32
2026-04-21 16:44:28 +03:30
75b7e5ecad daghi problem fixed , 2 apis for reporting counts added 2026-04-21 16:43:15 +03:30
a3156881b8 Merge pull request 'daghi + expert and damage expert name in decision +' (#31) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#31
2026-04-20 18:20:34 +03:30
41c44dcf23 daghi + expert and damage expert name in decision +
signature required added
2026-04-20 18:19:25 +03:30
SepehrYahyaee
95bfc095ce Fixed some bugs on FaTimestamps 2026-04-20 12:11:12 +03:30
b63c2155bc Merge pull request 'blame status agreement fixed' (#30) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#30
2026-04-20 11:22:04 +03:30
9463142ecf blame status agreement fixed 2026-04-20 11:19:49 +03:30
a71c4ea980 Merge pull request 'YARA-814' (#29) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#29
2026-04-20 10:49:28 +03:30
SepehrYahyaee
b826a133a4 YARA-814 2026-04-20 10:49:06 +03:30
b4501c503e Merge pull request 'main' (#28) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#28
2026-04-20 10:02:28 +03:30
SepehrYahyaee
a0d337aff0 Merge remote-tracking branch 'origin/main' 2026-04-20 10:00:02 +03:30
28e2de459a Merge pull request 'main' (#27) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#27
2026-04-20 09:53:53 +03:30
af1a07743e merge upstream 2026-04-19 17:23:47 +03:30
fca88bc151 update the resend request and user side 2026-04-19 17:23:14 +03:30
SepehrYahyaee
e264695db3 YARA-803 2026-04-19 13:08:47 +03:30
277f2d4d66 Merge pull request 'Fixed lock mechanism for damage-expert' (#26) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#26
2026-04-19 12:32:22 +03:30
SepehrYahyaee
514018beb1 Fixed lock mechanism for damage-expert 2026-04-19 12:32:03 +03:30
374b15933d Merge pull request 'add blame in claim details plus auto expert decision bug fixed' (#25) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#25
2026-04-19 11:59:33 +03:30
08a4d754c1 add blame in claim details plus auto expert decision bug fixed 2026-04-19 11:57:18 +03:30
c5b2d7b520 Merge pull request 'Fixed steps' (#24) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#24
2026-04-19 10:42:05 +03:30
SepehrYahyaee
5a84080bf1 Fixed steps 2026-04-19 10:41:51 +03:30
68b3b2000b Merge pull request 'Fixed statuses and switched some' (#23) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#23
2026-04-18 17:42:27 +03:30
SepehrYahyaee
028ca899fe resolved conflicts and maintained new features 2026-04-18 17:41:45 +03:30
SepehrYahyaee
0086b8db4d fixed many bugs and step issues 2026-04-18 17:04:19 +03:30
SepehrYahyaee
7f5b64f2a6 Fixed DTO 2026-04-18 14:02:50 +03:30
2898897dd9 Merge pull request 'lookups api is offline' (#22) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#22
2026-04-18 13:29:11 +03:30
e8a3873851 lookups api is offline 2026-04-18 13:26:37 +03:30
131df63a3f Merge pull request 'main' (#21) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#21
2026-04-18 12:35:41 +03:30
b9eb0ab5bd Merge remote-tracking branch 'upstream/main' | Merge upstream into main and reapply local changes 2026-04-18 12:30:32 +03:30
4bdb9fd469 update some important issues 2026-04-18 10:49:22 +03:30
38f700c3d7 Merge pull request 'Fixed bugs of car-body steps' (#19) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#19
2026-04-18 10:10:58 +03:30
SepehrYahyaee
1a1d55bc2e Fixed bugs of car-body steps 2026-04-18 10:10:19 +03:30
a2f1d5ea7f Merge pull request 'YARA-725' (#18) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#18
2026-04-15 12:32:53 +03:30
SepehrYahyaee
9a65071276 YARA-725 2026-04-15 12:32:25 +03:30
12d6fa4d73 Merge pull request 'YARA-784, YARA-789, YARA-791' (#17) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#17
2026-04-15 11:58:36 +03:30
SepehrYahyaee
640b240ada YARA-791 2026-04-15 11:57:09 +03:30
SepehrYahyaee
f8fbbb7ac6 YARA-784 2026-04-15 11:43:19 +03:30
SepehrYahyaee
0e2789c209 YARA-789 2026-04-15 10:16:18 +03:30
7661862564 Merge pull request 'Fixed reports+insurer + fixed field_expert not accessing claim APIs temporarily' (#16) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#16
2026-04-13 14:10:39 +03:30
SepehrYahyaee
a2143fe1c5 Fixed field_expert not accessing claim apis 2026-04-13 14:09:29 +03:30
Soheil Hajizadeh
494e3d93ab update the expert claim dto 2026-04-11 09:46:54 +03:30
9afb079786 Rework the reports and insurer part 2026-04-11 00:12:10 +03:30
25958bb966 merge upstream 2026-04-08 16:09:14 +03:30
Soheil Hajizadeh
1d51370b3e vehicle data added to get single request by expert , also submit in person 2026-04-08 16:08:31 +03:30
e44721b7be Merge pull request 'YARA-743, YARA-763, YARA-764' (#15) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#15
2026-04-06 15:00:02 +03:30
7ef057dc1f YARA-764 2026-04-06 14:33:33 +03:30
1b68a81204 YARA-763 2026-04-06 14:15:31 +03:30
f77da50d1f YARA-743 2026-04-06 14:01:45 +03:30
79c2982e19 Merge pull request 'Fixed log for error' (#14) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#14
2026-04-05 16:45:12 +03:30
fb4d166c31 Fixed log for error 2026-04-05 16:44:14 +03:30
9084f8fafb Merge pull request 'update the user side from the list of the user requests' (#13) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#13
2026-04-04 14:03:18 +03:30
b216a363fb merge upstream 2026-03-29 12:22:53 +03:30
Soheil Hajizadeh
b2391751f0 update the user side for the requests list 2026-03-29 12:21:01 +03:30
df6d2bd04d Merge pull request 'Mocked all external API calls' (#12) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#12
2026-03-29 11:48:37 +03:30
f277d87dfa Mocked all external API calls 2026-03-29 11:46:56 +03:30
672ec25438 Merge pull request 'Fixed signature error for car-body' (#11) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#11
2026-03-29 10:43:33 +03:30
aa871c86f6 Fixed signature error for car-body 2026-03-29 10:41:38 +03:30
553a34054c Merge pull request 'Added registrar + fixed sign bug in car-body flow' (#10) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#10
2026-03-26 16:37:33 +03:30
8af152abc0 Added registrar + fixed sign bug in car-body flow 2026-03-26 16:35:02 +03:30
0446c83b36 Merge pull request 'update the blame service' (#9) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#9
2026-03-18 15:53:40 +03:30
255cfd3eb3 Merge pull request 'Disabled Inquiry' (#8) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#8
2026-03-17 18:55:18 +03:30
b1be5b1e09 Disabled Inquiry 2026-03-17 18:53:47 +03:30
7d3565ec51 Merge pull request 'Added Expert initiated flow (field expert) + deactivated inquiry' (#7) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#7
2026-03-16 15:45:49 +03:30
fc599acf4a Added Field Expert flows, and deactivated inquiry 2026-03-16 15:39:39 +03:30
SepehrYahyaee
b40270f058 Added refactored version of car-body 2026-03-14 13:36:05 +03:30
Soheil Hajizadeh
8d6fa3daac update the blame service 2026-03-10 01:06:30 +03:30
64dfd1ca8a Merge pull request 'historical refactored yara724 merge pull request' (#6) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#6
2026-02-24 12:24:56 +03:30
665ed9e70f Merge pull request 'blame and claim refactored' (#1) from integrate-my-work into main
Reviewed-on: #1
2026-02-24 12:21:43 +03:30
147 changed files with 18376 additions and 4591 deletions

2897
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@nestjs-modules/mailer": "^1.8.1", "@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3", "@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
@@ -44,6 +45,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2", "form-data": "^4.0.2",

View File

@@ -2,7 +2,10 @@
export enum WorkflowStep { export enum WorkflowStep {
CREATED = "CREATED", CREATED = "CREATED",
// ---------- CAR_BODY only (no confession; accident type form) ----------
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
// ---------- First party ---------- // ---------- First party ----------
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION", FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
FIRST_VIDEO = "FIRST_VIDEO", FIRST_VIDEO = "FIRST_VIDEO",

View File

@@ -1,19 +1,21 @@
//! NEW //! NEW
export enum CaseStatus { export enum CaseStatus {
OPEN = "OPEN", OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED" AUTO_CLOSED = "AUTO_CLOSED",
}
STOPPED = "STOPPED"
}

View File

@@ -0,0 +1,92 @@
import { ResendItemType } from "./resendItemType.enum";
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
/**
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
*/
export function normalizeResendRequestedItemKey(raw: string): string | null {
const t = String(raw ?? "").trim();
if (!t) return null;
if (RESEND_ITEM_VALUES.has(t)) return t;
const lower = t.toLowerCase();
for (const v of RESEND_ITEM_VALUES) {
if (v.toLowerCase() === lower) return v;
}
return null;
}
/** Deduplicated list of valid requested item keys. */
export function normalizeResendRequestedItemsList(
items: string[] | undefined | null,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of items || []) {
const c = normalizeResendRequestedItemKey(String(raw));
if (c && !seen.has(c)) {
seen.add(c);
out.push(c);
}
}
return out;
}
/**
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
*/
export function cloneResendUploadedDocuments(
raw: unknown,
): Record<string, unknown> {
if (raw == null || typeof raw !== "object") return {};
if (raw instanceof Map) {
const o: Record<string, unknown> = {};
for (const [k, v] of raw.entries()) {
o[String(k)] = v;
}
return o;
}
return { ...(raw as Record<string, unknown>) };
}
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
export function getResendItemInputKind(item: string): ResendItemInputKind {
if (item === ResendItemType.VOICE) return "voice";
if (item === ResendItemType.VIDEO) return "video";
if (item === ResendItemType.DESCRIPTION) return "text";
return "document_camera";
}
export function buildResendItemsWithUi(requestedItems: string[]) {
const normalized = normalizeResendRequestedItemsList(requestedItems);
return normalized.map((item) => ({
item,
inputKind: getResendItemInputKind(item),
}));
}
/** Whether a single requested item is satisfied on a party's resend row (after merges). */
export function isResendPartyItemSatisfied(
item: string,
row: {
uploadedDocuments?: Record<string, unknown>;
resendVoiceId?: unknown;
resendVideoId?: unknown;
userTextDescription?: string;
},
): boolean {
const uploaded = row.uploadedDocuments || {};
if (item === ResendItemType.DESCRIPTION) {
return !!(row.userTextDescription && String(row.userTextDescription).trim());
}
if (item === ResendItemType.VOICE) {
return !!row.resendVoiceId;
}
if (item === ResendItemType.VIDEO) {
return !!row.resendVideoId;
}
return !!uploaded[item];
}

View File

@@ -5,8 +5,12 @@ export enum ResendItemType {
CAR_CERTIFICATE = "carCertificate", CAR_CERTIFICATE = "carCertificate",
DRIVING_LICENSE = "drivingLicense", DRIVING_LICENSE = "drivingLicense",
CAR_GREEN_CARD = "carGreenCard", CAR_GREEN_CARD = "carGreenCard",
PLATE = "plate",
CAR_PLATE = "carPlate",
// Media evidence // Media evidence
VOICE = "voice", VOICE = "voice",
VIDEO = "video", VIDEO = "video",
}
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
DESCRIPTION = "description",
}

View File

@@ -10,11 +10,30 @@ export enum ClaimCaseStatus {
// User flow - damage capture // User flow - damage capture
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES", CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
// Expert flow // Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT", WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
EXPERT_REVIEWING = "EXPERT_REVIEWING", EXPERT_REVIEWING = "EXPERT_REVIEWING",
/**
* @deprecated Prefer specific post-expert statuses below. Kept for existing DB rows and reads.
* Historically used for all owner/insurer steps after expert pricing.
*/
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL", WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
/** Expert reply has only priced lines (`factorNeeded=false` everywhere). Owner final accept/reject at `INSURER_REVIEW`. Also set after expert finishes repair-factor validation when the case returns to final owner sign-off. */
INSURER_REVIEW_AWAITING_OWNER_SIGN = "INSURER_REVIEW_AWAITING_OWNER_SIGN",
/** Expert reply mixes priced lines and factor-needed lines: owner signs priced lines, then uploads factors (status stays through both sub-steps). */
INSURER_REVIEW_MIXED_FACTORS_PENDING = "INSURER_REVIEW_MIXED_FACTORS_PENDING",
/** Expert reply requires a repair-factor file for every line before cost validation. */
OWNER_REPAIR_FACTOR_UPLOAD_PENDING = "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
// Final states // Final states
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",

View File

@@ -6,20 +6,28 @@ export enum ClaimWorkflowStep {
// User: Damage selection phase // User: Damage selection phase
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS", SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS", SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
// User: Documentation phase // User: Damage capture phase (angles + per-part photos) — before document upload in v2
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User: Damage capture phase (per part)
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES", CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
// User: Documentation phase — after captures in v2
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User submission complete // User submission complete
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE", USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
// Expert: Damage assessment // Expert: Damage assessment
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT", EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
/** After user objection: damage experts last priced reply (stored in evaluation.damageExpertReplyFinal) */
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION", EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
// Insurer approval // Insurer approval
INSURER_REVIEW = "INSURER_REVIEW", INSURER_REVIEW = "INSURER_REVIEW",

View File

@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
CarCertificate = "carCertificate", CarCertificate = "carCertificate",
DrivingLicense = "drivingLicense", DrivingLicense = "drivingLicense",
CarGreenCard = "carGreenCard", CarGreenCard = "carGreenCard",
} Plate = "plate",
CarPlate = "carPlate",
}

View File

@@ -1,6 +1,9 @@
export enum ClaimRequiredDocumentType { export enum ClaimRequiredDocumentType {
// Car green card // Car green card
CAR_GREEN_CARD = "car_green_card", CAR_GREEN_CARD = "car_green_card",
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
NATIONAL_CARD = "national_card",
// Damaged party documents // Damaged party documents
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back", DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",

View File

@@ -2,6 +2,7 @@ export enum RoleEnum {
EXPERT = "expert", EXPERT = "expert",
DAMAGE_EXPERT = "damage_expert", DAMAGE_EXPERT = "damage_expert",
FIELD_EXPERT = "field_expert", FIELD_EXPERT = "field_expert",
REGISTRAR = "registrar",
COMPANY = "company", COMPANY = "company",
ADMIN = "admin", ADMIN = "admin",
USER = "user", USER = "user",

View File

@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
username: process.env.AI_USERNAME, username: process.env.AI_USERNAME,
password: process.env.AI_PASSWORD, password: process.env.AI_PASSWORD,
}, },
timeout: 30000, // 30 second timeout timeout: 1000, // 30 second timeout
}; };
private get profileOptions(): AxiosRequestConfig { private get profileOptions(): AxiosRequestConfig {

View File

@@ -19,6 +19,7 @@ import { SandHubModule } from "./sand-hub/sand-hub.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module"; import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
@@ -44,6 +45,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
authMechanism: "SCRAM-SHA-256", authMechanism: "SCRAM-SHA-256",
tls: true, tls: true,
tlsAllowInvalidCertificates: true, tlsAllowInvalidCertificates: true,
connectionFactory: (connection) => {
applyIranFaTimestampPlugin(connection);
return connection;
},
}, },
), ),
UsersModule, UsersModule,

View File

@@ -11,6 +11,7 @@ import {
import { import {
ApiBody, ApiBody,
ApiAcceptedResponse, ApiAcceptedResponse,
ApiOperation,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
ApiBearerAuth, ApiBearerAuth,
@@ -23,6 +24,7 @@ import {
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto"; import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto"; import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto"; import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
import { import {
GenuineRegisterDto, GenuineRegisterDto,
InsurerRegisterDto, InsurerRegisterDto,
@@ -38,13 +40,33 @@ import { CurrentUser } from "src/decorators/user.decorator";
export class ActorAuthController { export class ActorAuthController {
constructor(private readonly actorAuthService: ActorAuthService) {} constructor(private readonly actorAuthService: ActorAuthService) {}
/**
* @deprecated Use the unified actor onboarding flow instead. This endpoint
* will be removed in a future release.
*/
@Post("register/genuine") @Post("register/genuine")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Genuine actor registration",
description:
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
})
@ApiBody({ type: GenuineRegisterDto }) @ApiBody({ type: GenuineRegisterDto })
async registerGenuine(@Body() body: GenuineRegisterDto) { async registerGenuine(@Body() body: GenuineRegisterDto) {
return await this.actorAuthService.genuineRegister(body); return await this.actorAuthService.genuineRegister(body);
} }
/**
* @deprecated Use the unified actor onboarding flow instead. This endpoint
* will be removed in a future release.
*/
@Post("register/legal") @Post("register/legal")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Legal actor registration",
description:
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
})
@ApiBody({ type: LegalRegisterDto }) @ApiBody({ type: LegalRegisterDto })
async registerLegal(@Body() body: LegalRegisterDto) { async registerLegal(@Body() body: LegalRegisterDto) {
return await this.actorAuthService.legalRegister(body); return await this.actorAuthService.legalRegister(body);
@@ -64,12 +86,55 @@ export class ActorAuthController {
return await this.actorAuthService.createFieldExpertMock(body); return await this.actorAuthService.createFieldExpertMock(body);
} }
/** Mock: create a registrar for testing. Make private later. */
@Post("create-registrar")
@ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) {
return await this.actorAuthService.createRegistrarMock(body);
}
@UseGuards(LocalActorAuthGuard) @UseGuards(LocalActorAuthGuard)
@Post("login") @Post("login")
@Roles() @Roles()
@ApiOperation({
summary: "Actor login (returns access + refresh tokens)",
description:
"Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `[\"damage_expert\"]` — together with the actor's email/`username` and password. On success the response contains the JWT pair and the resolved profile.",
})
@ApiBody({ @ApiBody({
type: LoginActorDto, type: LoginActorDto,
description: "user verify otp -- call this api and get a tokens", description:
"Login payload. Pick one of the swagger examples below to see the exact body shape per role.",
examples: {
company: {
summary: "Insurer / company portal",
description: "Sample tenant credentials for the insurer (company) panel.",
value: {
role: "company",
username: "saman_insurer@gmail.com",
password: "123321",
},
},
expert: {
summary: "Blame expert panel",
description: "Sample credentials for a blame (`expert`) account.",
value: {
role: "expert",
username: "blame@gmail.com",
password: "123321",
},
},
damage_expert: {
summary: "Damage expert (claim) panel",
description: "Sample credentials for a damage-expert account.",
value: {
role: "damage_expert",
username: "claim@gmail.com",
password: "123321",
},
},
},
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @ClientKey() client) { async login(@Body() body, @Req() req, @ClientKey() client) {

View File

@@ -25,9 +25,10 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service"; // import { MailService } from "src/utils/mail/mail.service";
import { OtpService } from "src/utils/otp/otp.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -46,10 +47,11 @@ export class ActorAuthService {
private readonly expertDbService: ExpertDbService, private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used // private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService, private readonly otpService: OtpGeneratorService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -79,6 +81,13 @@ export class ActorAuthService {
else else
res = await this.fieldExpertDbService.findOne({ email: username }); res = await this.fieldExpertDbService.findOne({ email: username });
break; break;
case RoleEnum.REGISTRAR:
if (username == null && userId)
res = await this.registrarDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.registrarDbService.findOne({ email: username });
break;
case RoleEnum.COMPANY: case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username }); res = await this.insurerExpertDbService.findOne({ email: username });
break; break;
@@ -273,6 +282,10 @@ export class ActorAuthService {
actor = await this.fieldExpertDbService.findOne(filter); actor = await this.fieldExpertDbService.findOne(filter);
dbServiceToUpdate = this.fieldExpertDbService as any; dbServiceToUpdate = this.fieldExpertDbService as any;
} }
if (!actor) {
actor = await this.registrarDbService.findOne(filter);
dbServiceToUpdate = this.registrarDbService as any;
}
if (!actor) { if (!actor) {
throw new NotFoundException("actor not found"); throw new NotFoundException("actor not found");
@@ -309,6 +322,10 @@ export class ActorAuthService {
userExist = await this.fieldExpertDbService.findOne({ email }); userExist = await this.fieldExpertDbService.findOne({ email });
dbServiceToUpdate = this.fieldExpertDbService; dbServiceToUpdate = this.fieldExpertDbService;
} }
if (!userExist) {
userExist = await this.registrarDbService.findOne({ email });
dbServiceToUpdate = this.registrarDbService as any;
}
if (!userExist) throw new NotFoundException("user not found"); if (!userExist) throw new NotFoundException("user not found");
const decodeOtp = await this.hashService.compare(otp, userExist.otp); const decodeOtp = await this.hashService.compare(otp, userExist.otp);
if (!decodeOtp) throw new UnauthorizedException("otp invalid"); if (!decodeOtp) throw new UnauthorizedException("otp invalid");
@@ -383,6 +400,7 @@ export class ActorAuthService {
"phone", "phone",
"mobile", "mobile",
], ],
registrar: ["email"],
}; };
const allowedFields = allowedFieldsByRole[role]; const allowedFields = allowedFieldsByRole[role];
@@ -498,4 +516,34 @@ export class ActorAuthService {
throw er; throw er;
} }
} }
async createRegistrarMock(body: {
email: string;
password: string;
clientId: string;
}) {
const hashPassword = await this.hashService.hash(body.password);
const payload = {
email: body.email.toLowerCase().trim(),
password: hashPassword,
clientKey: new Types.ObjectId(body.clientId),
role: RoleEnum.REGISTRAR,
};
try {
const created = await this.registrarDbService.create(payload as any);
return {
id: (created as any)._id,
email: (created as any).email,
clientKey: (created as any).clientKey,
role: (created as any).role,
};
} catch (er) {
if (er.code === 11000) {
throw new BadRequestException(
"A registrar with this email already exists.",
);
}
throw er;
}
}
} }

View File

@@ -10,10 +10,10 @@ import {
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
@@ -24,8 +24,8 @@ export class UserAuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpService, private readonly otpCreator: OtpGeneratorService,
private readonly smsManagerService: SmsManagerService, private readonly smsOrchestrationService: SmsOrchestrationService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<any> { async validateUser(username: string, pass: string): Promise<any> {
@@ -71,6 +71,12 @@ export class UserAuthService {
}); });
const otp = this.otpCreator.create(); const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, mobile);
/// create otp request /// create otp request
@@ -90,9 +96,7 @@ export class UserAuthService {
city: "", city: "",
address: "", address: "",
state: "", state: "",
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}); });
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
@@ -105,9 +109,7 @@ export class UserAuthService {
}, },
{ {
otp: hashOtp, otp: hashOtp,
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}, },
); );
if (updateTokens) return new LoginDtoRs(userExist); if (updateTokens) return new LoginDtoRs(userExist);
@@ -115,25 +117,19 @@ export class UserAuthService {
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
return this.smsManagerService const ok = await this.smsOrchestrationService.sendAuthOtp(
.verifyLookUp({ mobile,
token: otp, otp,
template: process.env.AUTH_SMS_TEMPLATE, process.env.AUTH_SMS_TEMPLATE,
receptor: mobile, );
}) if (!ok) {
.then((smsRes) => { throw new HttpException(
this.logger.log( "auth sms send failed",
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `, HttpStatus.BAD_GATEWAY,
); );
}) }
.catch((er) => { this.logger.log(
this.logger.error( `Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `, );
);
throw new HttpException(
" auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR,
);
});
} }
} }

View File

@@ -11,8 +11,7 @@ import { ClientModule } from "src/client/client.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module"; // import { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
@Module({ @Module({
imports: [ imports: [
@@ -20,9 +19,8 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,
OtpModule,
PassportModule, PassportModule,
SmsManagerModule, SmsOrchestrationModule,
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" },
global: true, global: true,

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsMongoId, IsString, MinLength } from "class-validator";
export class CreateRegistrarDto {
@ApiProperty({ example: "registrar@sample.com" })
@IsEmail()
email: string;
@ApiProperty({ example: "securePassword123", minLength: 6 })
@IsString()
@MinLength(6)
password: string;
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
@IsMongoId()
clientId: string;
}

View File

@@ -44,6 +44,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
RoleEnum.DAMAGE_EXPERT, RoleEnum.DAMAGE_EXPERT,
RoleEnum.COMPANY, RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT, RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");

View File

@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request); const token = this.extractTokenFromHeader(request);
if (!token) { if (!token) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.SECRET}`,
}); });
if (payload.role !== RoleEnum.USER) {
console.log( if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
request.user.role,
);
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
request.user = payload; request.user = payload;
request.identity = request.user; request.identity = request.user;
} catch { } catch {

View File

@@ -22,9 +22,9 @@ import {
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiTags, ApiTags,
ApiOperation,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
) {} ) {}
@ApiParam({ name: "blameId" }) @ApiParam({ name: "blameId" })
@ApiOperation({ deprecated: true })
@Post("/:blameId") @Post("/:blameId")
async createClaimRequest( async createClaimRequest(
@Param("blameId") requestId: string, @Param("blameId") requestId: string,
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
} }
@ApiBody({ type: CarDamagePartDto }) @ApiBody({ type: CarDamagePartDto })
@ApiOperation({ deprecated: true })
@Patch("/car-part-damage/:claimRequestID") @Patch("/car-part-damage/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
async carPartDamage( async carPartDamage(
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("/car-other-part") @Get("/car-other-part")
async getCarOtherParts() { async getCarOtherParts() {
const carOtherPart = await readFile( const carOtherPart = await readFile(
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
}), }),
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ deprecated: true })
@Patch("/car-other-part-damage/:claimRequestID") @Patch("/car-other-part-damage/:claimRequestID")
async carOtherPartDamage( async carOtherPartDamage(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("required-documents-status/:claimRequestID") @Get("required-documents-status/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
async getRequiredDocumentsStatus( async getRequiredDocumentsStatus(
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("car-part-image-required/:claimRequestID") @Get("car-part-image-required/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
async getImageRequired(@Param("claimRequestID") requestId) { async getImageRequired(@Param("claimRequestID") requestId) {
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
enum: ClaimRequiredDocumentType, enum: ClaimRequiredDocumentType,
description: "Type of required document to upload", description: "Type of required document to upload",
}) })
@ApiOperation({ deprecated: true })
@Patch("upload-required-document/:claimRequestID") @Patch("upload-required-document/:claimRequestID")
async uploadRequiredDocument( async uploadRequiredDocument(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
name: "partId", name: "partId",
description: "The ID of the specific car part being photographed.", description: "The ID of the specific car part being photographed.",
}) })
@ApiOperation({ deprecated: true })
@Patch("capture-car-part-damage/:claimRequestID/:partId") @Patch("capture-car-part-damage/:claimRequestID/:partId")
async captureCarPartDamage( async captureCarPartDamage(
@Param("partId") partId: string, @Param("partId") partId: string,
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
@ApiOperation({ deprecated: true })
@Patch("car-capture/:claimRequestID") @Patch("car-capture/:claimRequestID")
async captureVideoCapture( async captureVideoCapture(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("requests/") @Get("requests/")
async getRequest(@CurrentUser() currentUser) { async getRequest(@CurrentUser() currentUser) {
return await this.claimRequestManagementService.myRequests(currentUser); return await this.claimRequestManagementService.myRequests(currentUser);
} }
@ApiOperation({ deprecated: true })
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
myRequests( myRequests(
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
return this.claimRequestManagementService.requestDetails(requestId, user); return this.claimRequestManagementService.requestDetails(requestId, user);
} }
@ApiOperation({ deprecated: true })
@Put("request/reply/:claimRequestId") @Put("request/reply/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@UseInterceptors( @UseInterceptors(
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Put("request/resend/:claimRequestId/objection") @Put("request/resend/:claimRequestId/objection")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiConsumes("application/json") @ApiConsumes("application/json")
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Patch("request/resend/:claimRequestId") @Patch("request/resend/:claimRequestId")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
* User satisfaction rating for a completed claim file. * User satisfaction rating for a completed claim file.
* Only the damaged user (claim owner) can rate their claim after it is closed. * Only the damaged user (claim owner) can rate their claim after it is closed.
*/ */
@ApiOperation({ deprecated: true })
@Put("request/:claimRequestId/user-rating") @Put("request/:claimRequestId/user-rating")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UserRatingDto }) @ApiBody({ type: UserRatingDto })
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Patch("request/reply/:claimRequestId/:partId/upload-factor") @Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
@ApiBody({ type: InPersonVisitDto }) @ApiBody({ type: InPersonVisitDto })
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit") @Patch(":id/visit")
async inPersonVisit( async inPersonVisit(
@Param("id") requestId: string, @Param("id") requestId: string,
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("branches/:insuranceId") @Get("branches/:insuranceId")
async insuranceBranches(@Param("insuranceId") insuranceId: string) { async insuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches( return await this.claimRequestManagementService.retrieveInsuranceBranches(
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("fanavaran-submit/:claimRequestId") @Get("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) { async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true })
@Post("fanavaran-submit/:claimRequestId") @Post("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) { async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {

View File

@@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ClaimRequestManagementController } from "./claim-request-management.controller"; import { ClaimRequestManagementController } from "./claim-request-management.controller";
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
@@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimAccessGuard, ClaimAccessGuard,
], ],
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller], controllers: [
ClaimRequestManagementController,
ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller,
],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,
ClaimRequestManagementDbService, ClaimRequestManagementDbService,
@@ -93,6 +98,7 @@ import { JwtModule } from "@nestjs/jwt";
DamageImageDbService, DamageImageDbService,
VideoCaptureDbService, VideoCaptureDbService,
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimSignDbService,
], ],
}) })
export class ClaimRequestManagementModule {} export class ClaimRequestManagementModule {}

View File

@@ -2,38 +2,54 @@ import {
Controller, Controller,
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
BadRequestException,
Param, Param,
Query,
Post, Post,
Patch, Patch,
Put,
Body, Body,
UseGuards, UseGuards,
Get, Get,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
} from "@nestjs/common"; } from "@nestjs/common";
import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "path"; import { extname } from "path";
import { Types } from "mongoose";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto"; import {
OuterPartCatalogItemDto,
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto"; import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto"; import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto"; import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto"; import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@ApiTags("claim-request-management (v2)") @ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER) @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -51,7 +67,7 @@ export class ClaimRequestManagementV2Controller {
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub); return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -68,7 +84,8 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiOperation({ @ApiOperation({
summary: "Get Claim Details (V2)", summary: "Get Claim Details (V2)",
description: "Get full details of a claim request. Only the claim owner can access.", description:
"Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -91,6 +108,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2( return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -100,6 +118,207 @@ export class ClaimRequestManagementV2Controller {
} }
} }
/**
* V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos).
*/
@Post("request/:claimRequestId/expert-resend/acknowledge")
@ApiOperation({
summary: "Acknowledge expert resend (instructions only)",
description:
"Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " +
"Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.",
})
@ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
async acknowledgeExpertResend(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2(
claimRequestId,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
);
}
}
/**
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
*/
@Put("request/:claimRequestId/objection")
@ApiOperation({
summary: "Submit user objection (V2)",
description:
"**Windows:** (1) **Insurer-review:** `ClaimCaseStatus` in **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, with `workflow.currentStep=INSURER_REVIEW` and no recorded **final** `evaluation.ownerInsurerApproval` — including **mixed** priced+factor gate (`NEEDS_REVISION` before priced-line signature for factors) or **final** totals (`claimStatus=APPROVED`). Not allowed while uploading factors (`OWNER_UPLOAD_FACTOR_DOCUMENTS`) or expert validation (`EXPERT_COST_EVALUATION`/`EXPERT_VALIDATING_REPAIR_FACTORS`).\n" +
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({ type: UserObjectionV2Dto })
@ApiResponse({ status: 200, description: "Objection stored" })
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" })
async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit objection",
);
}
}
/**
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
*/
@Put("request/:claimRequestId/user-rating")
@ApiOperation({
summary: "Submit user satisfaction rating (V2)",
description:
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
"Stores scores on `ClaimCase.userRating` (05). One submission per case.",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({ type: UserRatingDto })
@ApiResponse({ status: 200, description: "Rating saved" })
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Rating already submitted" })
async addUserRatingV2(
@Param("claimRequestId") claimRequestId: string,
@Body() ratingDto: UserRatingDto,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.addUserRatingV2(
claimRequestId,
ratingDto,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to save rating",
);
}
}
/**
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
*/
@Put("request/:claimRequestId/owner-insurer-approval/sign")
@ApiParam({
name: "claimRequestId",
description: "Claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Sign priced lines or final claim pricing (owner)",
description:
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
})
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: { type: "string", format: "binary", description: "Signature image" },
agree: {
type: "boolean",
description: "true to accept expert pricing and complete the claim",
},
branchId: {
type: "string",
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011",
},
},
},
})
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Already signed" })
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id");
}
const agreed =
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ @ApiParam({
name: "blameRequestId", name: "blameRequestId",
@@ -127,6 +346,66 @@ export class ClaimRequestManagementV2Controller {
/** /**
* V2 API: Select damaged outer car parts (Step 2 of claim workflow) * V2 API: Select damaged outer car parts (Step 2 of claim workflow)
*/ */
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(
@Query("carType") carType?: ClaimVehicleTypeV2,
): Promise<OuterPartCatalogItemDto[]> {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("branches/:insuranceId")
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
})
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@ApiResponse({
status: 200,
description: "List of branches for insurer",
})
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@Get("car-other-part")
@ApiOperation({
summary: "Get other (non-body) parts catalog",
description:
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
})
@ApiResponse({
status: 200,
description: "Other parts catalog",
})
async getCarOtherPartsV2() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Patch("select-outer-parts/:claimRequestId") @Patch("select-outer-parts/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)", summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
@@ -161,41 +440,35 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ @ApiBody({
type: SelectOuterPartsV2Dto, type: SelectOuterPartsV2Dto,
description: "Array of selected damaged outer parts", description:
"Selected vehicle type + selected outer part IDs from catalog",
examples: { examples: {
example1: { example1: {
summary: "Minor front damage", summary: "Sedan - minor front damage",
value: { value: {
selectedParts: ["hood", "front_bumper", "front_right_fender"], carType: "sedan",
selectedPartIds: [19, 21, 16],
}, },
}, },
example2: { example2: {
summary: "Side impact damage", summary: "SUV - left side impact",
value: { value: {
selectedParts: [ carType: "suv",
"front_left_door", selectedPartIds: [102, 103, 104, 107],
"rear_left_door",
"front_left_fender",
"rear_left_fender",
],
}, },
}, },
example3: { example3: {
summary: "Rear-end collision", summary: "Hatchback - rear-end collision",
value: { value: {
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"], carType: "hatchback",
selectedPartIds: [225, 226, 210],
}, },
}, },
example4: { example4: {
summary: "Multiple damage areas", summary: "Pickup - two sides + roof",
value: { value: {
selectedParts: [ carType: "pickup",
"hood", selectedPartIds: [319, 312, 330],
"front_bumper",
"front_right_door",
"rear_bumper",
"trunk",
],
}, },
}, },
}, },
@@ -231,6 +504,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -252,13 +526,14 @@ export class ClaimRequestManagementV2Controller {
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim) **Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** User selects non-body damaged parts and provides bank information for payment. **Purpose:** User selects non-body damaged parts and provides bank information for payment.
Optional: upload car green card file in the same step.
**Validations:** **Validations:**
- Claim must exist - Claim must exist
- User must be the claim owner (damaged party from blame case) - User must be the claim owner (damaged party from blame case)
- Current workflow step must be SELECT_OTHER_PARTS - Current workflow step must be SELECT_OTHER_PARTS
- Bank information must not have been submitted previously - Bank information must not have been submitted previously
- Sheba number must be exactly 24 digits - Sheba (sheba) accepted as IR + 24 digits or only 24 digits
- National code must be exactly 10 digits - National code must be exactly 10 digits
**Valid Other Parts (Optional):** **Valid Other Parts (Optional):**
@@ -267,8 +542,8 @@ export class ClaimRequestManagementV2Controller {
- headlight, taillight, mirror, glass - headlight, taillight, mirror, glass
**After Success:** **After Success:**
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4) - Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
- User can proceed to upload required documents - User captures car angles and damaged-part photos, then uploads required documents (Step 5)
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -276,42 +551,38 @@ export class ClaimRequestManagementV2Controller {
description: "The claim case ID (MongoDB ObjectId)", description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011", example: "507f1f77bcf86cd799439011",
}) })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-required-document",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `other-parts-${unique}${ex}`);
},
}),
}),
)
@ApiBody({ @ApiBody({
type: SelectOtherPartsV2Dto, description:
description: "Other parts selection and bank information", "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
examples: { schema: {
example1: { type: "object",
summary: "Only bank info (no other parts damaged)", properties: {
value: { otherParts: {
otherParts: [], oneOf: [
shebaNumber: "123456789012345678901234", { type: "array", items: { type: "string" } },
nationalCodeOfOwner: "1234567890", { type: "string", description: "JSON string array for multipart" },
}, ],
}, example: ["engine", "suspension"],
example2: {
summary: "Engine and suspension damage",
value: {
otherParts: ["engine", "suspension"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
},
},
example3: {
summary: "Multiple systems damaged",
value: {
otherParts: ["engine", "brake_system", "electrical", "headlight"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
},
},
example4: {
summary: "Lighting and glass damage",
value: {
otherParts: ["headlight", "taillight", "mirror", "glass"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
}, },
sheba: { type: "string", example: "IR123456789012345678901234" },
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
file: { type: "string", format: "binary" },
}, },
required: ["sheba", "nationalCodeOfInsurer"],
}, },
}) })
@ApiResponse({ @ApiResponse({
@@ -339,12 +610,15 @@ export class ClaimRequestManagementV2Controller {
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto, @Body() body: SelectOtherPartsV2Dto,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.selectOtherPartsV2( return await this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
file,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -364,11 +638,13 @@ export class ClaimRequestManagementV2Controller {
summary: "Get Capture Requirements (V2)", summary: "Get Capture Requirements (V2)",
description: ` description: `
**Get list of what needs to be captured:** **Get list of what needs to be captured:**
- Required documents (13 items) - Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
- Car angles (4 items: front, back, left, right) - Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). Returns status of each item (uploaded/captured or not).
**V2 order:** During \`CAPTURE_PART_DAMAGES\`, complete angles, part photos, walk-around video, and the three \`preferUploadDuringCapture\` documents (same upload-document API). Then complete the remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -397,6 +673,7 @@ Returns status of each item (uploaded/captured or not).
return await this.claimRequestManagementService.getCaptureRequirementsV2( return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -430,20 +707,19 @@ Returns status of each item (uploaded/captured or not).
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Upload Required Document (V2 - Step 4)", summary: "Upload Required Document (V2 - Step 5)",
description: ` description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim) **Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the 13 required documents:** **Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card
- damaged_driving_license_front/back - damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo - damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate - damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back - guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
- guilty_car_card_front/back, guilty_metal_plate
**When all 13 documents are uploaded:** **When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -459,7 +735,6 @@ Returns status of each item (uploaded/captured or not).
documentKey: { documentKey: {
type: "string", type: "string",
enum: [ enum: [
"car_green_card",
"damaged_driving_license_front", "damaged_driving_license_front",
"damaged_driving_license_back", "damaged_driving_license_back",
"damaged_chassis_number", "damaged_chassis_number",
@@ -473,7 +748,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_back", "guilty_car_card_back",
"guilty_metal_plate", "guilty_metal_plate",
], ],
example: "car_green_card", example: "damaged_driving_license_front",
}, },
file: { file: {
type: "string", type: "string",
@@ -507,6 +782,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -538,15 +814,17 @@ Returns status of each item (uploaded/captured or not).
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)", summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
description: ` description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim) **Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
**Capture types:** **Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required 1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2 2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.** **When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -598,6 +876,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -606,4 +885,129 @@ Returns status of each item (uploaded/captured or not).
); );
} }
} }
/**
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
*/
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload repair factor file for a factor-needed part (V2)",
description:
"Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" +
"**Requires:** `ClaimCaseStatus` **`OWNER_REPAIR_FACTOR_UPLOAD_PENDING`** or **`INSURER_REVIEW_MIXED_FACTORS_PENDING`** (or legacy **`WAITING_FOR_INSURER_APPROVAL`**), `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" +
"**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" +
"When every `factorNeeded` line has `factorLink`, the case moves to **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.",
})
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
@ApiParam({ name: "partId", description: "Part id from expert reply" })
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("file", {
storage: diskStorage({
destination: "./files/claim-factors",
filename: (req, file, callback) => {
const unique = Date.now();
callback(null, `-${unique}-${file.originalname}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
async uploadFactorForPartV2(
@Param("claimRequestId") claimRequestId: string,
@Param("partId") partId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.uploadClaimFactorV2(
claimRequestId,
partId,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload factor",
);
}
}
/**
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
*/
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `claim-video-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@Patch("car-capture/:claimRequestId")
@ApiOperation({
summary: "Upload car walk-around video (V2)",
description:
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiResponse({
status: 200,
description: "Video uploaded successfully",
type: VideoCaptureV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Video already uploaded" })
async captureVideoCaptureV2(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() user: any,
): Promise<VideoCaptureV2ResponseDto> {
try {
return await this.claimRequestManagementService.setVideoCaptureV2(
claimRequestId,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload car capture video",
);
}
}
} }

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
/** /**
* V2 DTO for capturing car angle or damaged part * V2 DTO for capturing car angle or damaged part
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
captureType: 'angle' | 'part'; captureType: 'angle' | 'part';
@ApiProperty({ @ApiProperty({
description: 'Key of the angle or part being captured', description:
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
example: 'front', example: 'front',
}) })
@IsNotEmpty({ message: 'Capture key is required' }) @IsNotEmpty({ message: 'Capture key is required' })
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
example: 'Angle captured successfully. 6 captures remaining.', example: 'Angle captured successfully. 6 captures remaining.',
}) })
message: string; message: string;
@ApiPropertyOptional({
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
})
expertResendComplete?: boolean;
}
/**
* Response DTO for car walk-around video upload (V2)
*/
export class VideoCaptureV2ResponseDto {
@ApiProperty({
description: 'Claim case ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'ID of the stored video document (claim-video-capture)',
example: '507f1f77bcf86cd799439012',
})
videoId: string;
@ApiProperty({
description: 'Success message',
example: 'Video capture uploaded successfully.',
})
message: string;
} }

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
/** /**
* DTO for required document item * DTO for required document item
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'], enum: ['general', 'damaged_party', 'guilty_party'],
}) })
category: string; category: string;
@ApiPropertyOptional({
description:
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
example: true,
})
preferUploadDuringCapture?: boolean;
} }
/** /**
@@ -71,10 +79,17 @@ export class CarAngleItem {
*/ */
export class DamagedPartItem { export class DamagedPartItem {
@ApiProperty({ @ApiProperty({
description: 'Part key', description:
example: 'hood', 'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
example: 'backfender',
}) })
key: string; name: string;
@ApiPropertyOptional({
description: 'Vehicle side / region (left, right, front, back, top)',
example: 'left',
})
side?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in Farsi', description: 'Display label in Farsi',
@@ -82,6 +97,12 @@ export class DamagedPartItem {
}) })
label_fa: string; label_fa: string;
@ApiPropertyOptional({
description: 'Deprecated: same as `name` (kept for older clients)',
example: 'backfender',
})
key?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in English', description: 'Display label in English',
example: 'Hood', example: 'Hood',
@@ -93,6 +114,12 @@ export class DamagedPartItem {
example: false, example: false,
}) })
captured: boolean; captured: boolean;
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
@ApiPropertyOptional({ example: 12 })
@IsOptional()
@IsInt()
id?: number;
} }
/** /**

View File

@@ -1,4 +1,78 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
export class ClaimDetailsOwnerNextActionV2Dto {
@ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' })
key: string;
@ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' })
method?: string;
@ApiPropertyOptional({
description: 'Path under API root',
example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign',
})
pathTemplate?: string;
@ApiProperty({ description: 'What this endpoint does for the user' })
description: string;
}
/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */
export class ClaimDetailsOwnerGuidanceV2Dto {
@ApiProperty({
description: 'Machine-readable phase',
enum: [
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPERT_RESEND',
'WAITING_DAMAGE_EXPERT',
'EXPERT_REVIEWING',
'USER_FLOW',
'UPLOAD_FACTORS',
'EXPERT_VALIDATING_FACTORS',
'SIGN_PRICED_LINES',
'INSURER_REVIEW_NEEDS_REVISION',
'FINAL_SIGN_OR_REJECT',
'INSURER_APPROVAL_FALLBACK',
],
})
phaseKey: string;
@ApiProperty({ description: 'Short headline for the current phase' })
headline: string;
@ApiPropertyOptional({ description: 'Longer UX copy' })
detail?: string;
@ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] })
nextActions: ClaimDetailsOwnerNextActionV2Dto[];
@ApiPropertyOptional({
description: 'Whether PUT objection is permitted (see server validation for exact rules)',
})
objectionAllowed?: boolean;
@ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' })
objectionHint?: string;
}
/** Active damage-expert resend request (owner must upload or acknowledge). */
export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional()
resendDescription?: string;
@ApiPropertyOptional({ type: [String] })
resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] })
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
fulfilledAt?: Date;
}
export class ClaimDetailsV2ResponseDto { export class ClaimDetailsV2ResponseDto {
@ApiProperty({ description: 'Claim case ID' }) @ApiProperty({ description: 'Claim case ID' })
@@ -10,7 +84,11 @@ export class ClaimDetailsV2ResponseDto {
@ApiProperty({ description: 'Request number' }) @ApiProperty({ description: 'Request number' })
requestNo: string; requestNo: string;
@ApiProperty({ description: 'Overall case status' }) @ApiProperty({
description:
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
})
status: string; status: string;
@ApiProperty({ description: 'Claim damage status' }) @ApiProperty({ description: 'Claim damage status' })
@@ -28,9 +106,13 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number' }) @ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string; blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Owner info' }) @ApiPropertyOptional({
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
})
owner?: { owner?: {
userId: string; userId: string;
clientId?: string;
userClientKey?: string;
fullName?: string; fullName?: string;
}; };
@@ -42,8 +124,11 @@ export class ClaimDetailsV2ResponseDto {
plate?: any; plate?: any;
}; };
@ApiPropertyOptional({ description: 'Selected outer damaged parts' }) @ApiPropertyOptional({
selectedParts?: string[]; description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[]; otherParts?: string[];
@@ -54,14 +139,73 @@ export class ClaimDetailsV2ResponseDto {
nationalCodeOfOwner?: string; nationalCodeOfOwner?: string;
}; };
@ApiPropertyOptional({ description: 'Required documents status' }) @ApiPropertyOptional({ description: 'Required documents status (link instead of id)' })
requiredDocuments?: Record<string, { uploaded: boolean; fileId?: string }>; requiredDocuments?: Record<string, { uploaded: boolean; fileUrl?: string }>;
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({
damagedParts?: Record<string, { captured: boolean; url?: string }>; description:
'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)',
type: 'array',
items: {
type: 'object',
properties: {
index: { type: 'number' },
id: { type: 'number', nullable: true },
name: { type: 'string' },
side: { type: 'string' },
label_fa: { type: 'string' },
captured: { type: 'boolean' },
url: { type: 'string' },
path: { type: 'string' },
fileName: { type: 'string' },
},
},
})
damagedParts?: Array<{
index: number;
id?: number | null;
name: string;
side: string;
label_fa: string;
captured: boolean;
url?: string;
path?: string;
fileName?: string;
}>;
@ApiPropertyOptional({
description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
})
expertResend?: ExpertResendDetailsV2Dto;
@ApiPropertyOptional({
description: "Damage expert opinion(s): initial and final (after objection).",
type: Object,
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
@ApiPropertyOptional({
type: ClaimDetailsOwnerGuidanceV2Dto,
description:
'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.',
})
ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto;
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
userRating?: {
progressSpeed: number;
registrationEase: number;
overallEvaluation: number;
comment?: string;
createdAt?: Date;
};
@ApiProperty({ description: 'Created at' }) @ApiProperty({ description: 'Created at' })
createdAt: string; createdAt: string;

View File

@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsInt, IsOptional, IsString } from "class-validator";
/** Stored shape / API payload for one selected outer damaged part (V2). */
export class DamageSelectedPartV2BodyDto {
@ApiPropertyOptional({ description: "Catalog id when from outer-parts catalog" })
@IsOptional()
@IsInt()
id?: number;
@ApiProperty({ example: "backfender" })
@IsString()
name: string;
@ApiProperty({ example: "left" })
@IsString()
side: string;
@ApiProperty({ example: "گلگیر عقب" })
@IsString()
label_fa: string;
@ApiPropertyOptional({ description: "Original catalog key, e.g. left_backfender" })
@IsOptional()
@IsString()
catalogKey?: string;
}

View File

@@ -10,7 +10,11 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim request number', example: 'CL12345' }) @ApiProperty({ description: 'Claim request number', example: 'CL12345' })
requestNo: string; requestNo: string;
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' }) @ApiProperty({
description:
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
})
status: string; status: string;
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' }) @ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })

View File

@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
otherParts?: OtherCarPart[]; otherParts?: OtherCarPart[];
@ApiProperty({ @ApiProperty({
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix', description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
example: 'IR123456789012345678901234',
})
@IsNotEmpty({ message: 'sheba is required' })
@IsString({ message: 'sheba must be a string' })
sheba: string;
@ApiPropertyOptional({
description: 'Legacy alias of sheba for backward compatibility',
example: '123456789012345678901234', example: '123456789012345678901234',
pattern: '^[0-9]{24}$',
minLength: 24,
maxLength: 24,
}) })
@IsNotEmpty({ message: 'Sheba number is required' }) @IsOptional()
@IsString({ message: 'Sheba number must be a string' }) @IsString()
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' }) shebaNumber?: string;
@Matches(/^[0-9]{24}$/, {
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
})
shebaNumber: string;
@ApiProperty({ @ApiProperty({
description: 'National code of the owner - 10 digits', description: 'National code of insurer/owner - 10 digits',
example: '1234567890', example: '1234567890',
pattern: '^[0-9]{10}$', pattern: '^[0-9]{10}$',
minLength: 10, minLength: 10,
maxLength: 10, maxLength: 10,
}) })
@IsNotEmpty({ message: 'National code of owner is required' }) @IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsString({ message: 'National code must be a string' }) @IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' }) @Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, { @Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits', message: 'National code must contain exactly 10 digits',
}) })
nationalCodeOfOwner: string; nationalCodeOfInsurer: string;
@ApiPropertyOptional({
description: 'Legacy alias for backward compatibility',
example: '1234567890',
})
@IsOptional()
@IsString()
nationalCodeOfOwner?: string;
} }
/** /**
@@ -108,19 +117,20 @@ export class SelectOtherPartsV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS', example: 'CAPTURE_PART_DAMAGES',
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Next possible workflow step', description: 'Next possible workflow step',
example: 'CAPTURE_PART_DAMAGES', example: 'UPLOAD_REQUIRED_DOCUMENTS',
}) })
nextStep: string; nextStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: 'Success message',
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.', example:
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
}) })
message: string; message: string;
} }

View File

@@ -1,5 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator'; import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
import {
ClaimVehicleTypeV2,
OuterPartSideV2,
} from "src/static/outer-car-parts-catalog";
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
/** /**
* Enum for valid outer car parts that can be damaged * Enum for valid outer car parts that can be damaged
@@ -43,7 +48,7 @@ export class SelectOuterPartsV2Dto {
minItems: 1, minItems: 1,
maxItems: 13, maxItems: 13,
}) })
@IsNotEmpty({ message: 'Selected parts array cannot be empty' }) @IsOptional()
@IsArray({ message: 'selectedParts must be an array' }) @IsArray({ message: 'selectedParts must be an array' })
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' }) @ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
@ArrayUnique({ message: 'Duplicate parts are not allowed' }) @ArrayUnique({ message: 'Duplicate parts are not allowed' })
@@ -51,7 +56,29 @@ export class SelectOuterPartsV2Dto {
each: true, each: true,
message: 'Invalid part name. Must be one of the valid outer car parts', message: 'Invalid part name. Must be one of the valid outer car parts',
}) })
selectedParts: OuterCarPart[]; selectedParts?: OuterCarPart[];
@ApiProperty({
description: 'Selected outer part IDs from catalog',
example: [9, 10, 4],
type: [Number],
required: false,
})
@IsOptional()
@IsArray({ message: 'selectedPartIds must be an array' })
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
selectedPartIds?: number[];
@ApiProperty({
description: 'Vehicle type for validating available outer parts',
enum: ClaimVehicleTypeV2,
required: true,
})
@IsNotEmpty()
@IsEnum(ClaimVehicleTypeV2)
carType?: ClaimVehicleTypeV2;
} }
/** /**
@@ -71,10 +98,18 @@ export class SelectOuterPartsV2ResponseDto {
publicId: string; publicId: string;
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged parts', description:
example: ['hood', 'front_right_door', 'rear_bumper'], "Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
type: [DamageSelectedPartV2BodyDto],
}) })
selectedParts: string[]; selectedParts: DamageSelectedPartV2BodyDto[];
@ApiProperty({
description: 'Selected part IDs',
example: [9, 7, 11],
type: [Number],
})
selectedPartIds: number[];
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',
@@ -94,3 +129,65 @@ export class SelectOuterPartsV2ResponseDto {
}) })
message: string; message: string;
} }
export class SetClaimVehicleTypeV2Dto {
@ApiProperty({
description: "Vehicle type selected by user",
enum: ClaimVehicleTypeV2,
})
@IsNotEmpty()
@IsEnum(ClaimVehicleTypeV2)
carType: ClaimVehicleTypeV2;
}
/**
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
* controllers). It mirrors how items are persisted under
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
* can match catalog rows to stored selections without any field renaming:
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
* parentheses, and the original full catalog key (`left_backfender`) is
* exposed as `catalogKey`.
*/
export class OuterPartCatalogItemDto {
@ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
})
id: number;
@ApiProperty({
description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty({
description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({
description:
"Display label in Farsi, with side disambiguator in parentheses when needed",
example: "چرخ عقب (چپ)",
})
label_fa: string;
@ApiProperty({
description: "Original full catalog key (matches stored `catalogKey`)",
example: "left_backWheel",
required: false,
})
catalogKey?: string;
@ApiProperty({
description: "Vehicle type this catalog row belongs to",
enum: ClaimVehicleTypeV2,
required: false,
example: "suv",
})
carType?: ClaimVehicleTypeV2;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
example: 'Document uploaded successfully. 12 documents remaining.', example: 'Document uploaded successfully. 12 documents remaining.',
}) })
message: string; message: string;
@ApiPropertyOptional({
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
})
expertResendComplete?: boolean;
} }

View File

@@ -0,0 +1,24 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsOptional, ValidateNested } from "class-validator";
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/**
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
* with nested validation enabled for the v2 controller pipeline.
*/
export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto)
newParts?: NewPartDto[];
}

View File

@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true }); return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
} }
async findOneAndUpdate(
filter: FilterQuery<ClaimCase>,
update: any,
options?: { new?: boolean },
): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findOneAndUpdate(filter, update, {
new: options?.new !== false,
});
}
async find( async find(
filter: FilterQuery<ClaimCase>, filter: FilterQuery<ClaimCase>,
options?: { lean?: boolean; select?: string }, options?: { lean?: boolean; select?: string },

View File

@@ -1,14 +1,15 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema } from "mongoose";
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema"; import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimDamageSelection { export class ClaimDamageSelection {
/** /**
* V2: Array of selected damaged outer car part names * V2: Selected outer damaged parts (ordered). Prefer objects
* Examples: ['hood', 'front_right_door', 'rear_bumper'] * `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated.
*/ */
@Prop({ type: [String], default: [] }) @Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
selectedParts?: string[]; selectedParts?: unknown[];
/** /**
* V2: Array of selected other (non-body) damaged parts * V2: Array of selected other (non-body) damaged parts
@@ -28,4 +29,3 @@ export class ClaimDamageSelection {
} }
export const ClaimDamageSelectionSchema = export const ClaimDamageSelectionSchema =
SchemaFactory.createForClass(ClaimDamageSelection); SchemaFactory.createForClass(ClaimDamageSelection);

View File

@@ -1,5 +1,9 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "mongoose"; import { Schema as MongooseSchema, Types } from "mongoose";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum"; import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
@@ -8,8 +12,13 @@ export class ClaimPartPricing {
@Prop({ type: String }) @Prop({ type: String })
partId: string; partId: string;
@Prop({ type: String }) /**
carPartDamage?: string; * Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
* Must be Mixed — objects cannot cast to String.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carPartDamage?: unknown;
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@@ -23,8 +32,9 @@ export class ClaimPartPricing {
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
totalPayment?: string | number; totalPayment?: string | number;
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
daghi?: string | number; daghi?: string | number | Record<string, unknown>;
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
factorNeeded?: boolean; factorNeeded?: boolean;
@@ -67,6 +77,43 @@ export class ClaimUserComment {
export const ClaimUserCommentSchema = export const ClaimUserCommentSchema =
SchemaFactory.createForClass(ClaimUserComment); SchemaFactory.createForClass(ClaimUserComment);
/** Owner acceptance + signature after expert pricing (post-expert insurer `INSURER_REVIEW` — `INSURER_REVIEW_AWAITING_OWNER_SIGN` / `INSURER_REVIEW_MIXED_FACTORS_PENDING` / legacy `WAITING_FOR_INSURER_APPROVAL`). */
@Schema({ _id: false })
export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerInsurerApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
@Schema({ _id: false })
export class ClaimOwnerPricedPartsApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerPricedPartsApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimExpertReply { export class ClaimExpertReply {
@Prop({ type: String }) @Prop({ type: String })
@@ -83,12 +130,17 @@ export class ClaimExpertReply {
@Prop({ type: ClaimUserCommentSchema }) @Prop({ type: ClaimUserCommentSchema })
userComment?: ClaimUserComment; userComment?: ClaimUserComment;
/** Damage expert profile at reply time (from `damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimExpertReplySchema = export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply); SchemaFactory.createForClass(ClaimExpertReply);
/** One line item the user disputes on an expert-priced part */
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjection { export class ClaimUserObjectionPart {
@Prop({ type: String, required: true }) @Prop({ type: String, required: true })
partId: string; partId: string;
@@ -103,9 +155,47 @@ export class ClaimUserObjection {
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
side?: string;
} }
export const ClaimUserObjectionSchema = export const ClaimUserObjectionPartSchema =
SchemaFactory.createForClass(ClaimUserObjection); SchemaFactory.createForClass(ClaimUserObjectionPart);
@Schema({ _id: false })
export class ClaimUserObjectionNewPart {
@Prop({ type: String })
partId?: string;
@Prop({ type: String, required: true })
partName: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
/**
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}.
*/
@Schema({ _id: false })
export class ClaimUserObjectionPayload {
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
objectionParts?: ClaimUserObjectionPart[];
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
newParts?: ClaimUserObjectionNewPart[];
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
}
export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimResendRequest { export class ClaimResendRequest {
@@ -117,6 +207,14 @@ export class ClaimResendRequest {
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) @Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendCarParts?: any[]; resendCarParts?: any[];
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
@Prop({ type: Date })
fulfilledAt?: Date;
/** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimResendRequestSchema = export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest); SchemaFactory.createForClass(ClaimResendRequest);
@@ -145,6 +243,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 }) @Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number; guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
} }
export const ClaimFileRatingSchema = export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating); SchemaFactory.createForClass(ClaimFileRating);
@@ -182,8 +283,8 @@ export class ClaimEvaluation {
@Prop({ type: ClaimResendRequestSchema }) @Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null; damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema }) @Prop({ type: ClaimUserObjectionPayloadSchema })
objection?: ClaimUserObjection; objection?: ClaimUserObjectionPayload;
@Prop({ type: ClaimUserResendPayloadSchema }) @Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload; userResendDocuments?: ClaimUserResendPayload;
@@ -191,11 +292,25 @@ export class ClaimEvaluation {
@Prop({ type: ClaimFileRatingSchema }) @Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating; rating?: ClaimFileRating;
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
@Prop({ type: String }) @Prop({ type: String })
visitLocation?: string; visitLocation?: string;
@Prop({ type: ClaimPriceDropSchema }) @Prop({ type: ClaimPriceDropSchema })
priceDrop?: ClaimPriceDrop; priceDrop?: ClaimPriceDrop;
/** Damage expert profile at last factor validation (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot;
/** Damage expert profile when in-person visit was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimEvaluationSchema = export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation); SchemaFactory.createForClass(ClaimEvaluation);

View File

@@ -50,6 +50,9 @@ export class ClaimOwner {
@Prop({ type: String }) @Prop({ type: String })
fullName?: string; fullName?: string;
@Prop({ type: Types.ObjectId })
clientId: Types.ObjectId;
} }
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner); export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);

View File

@@ -1,5 +1,5 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Schema as MongooseSchema, Types } from "mongoose";
import { AiImagesModel } from "./ai-image.schema"; import { AiImagesModel } from "./ai-image.schema";
import { CarGreenCardModel } from "./car-green-card.schema"; import { CarGreenCardModel } from "./car-green-card.schema";
import { ImageRequiredModel } from "./image-required.schema"; import { ImageRequiredModel } from "./image-required.schema";
@@ -20,6 +20,39 @@ export class CapturedImage {
} }
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage); export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
/** One row per selected damaged part (index-aligned with `damage.selectedParts`). */
@Schema({ _id: false })
export class DamagedPartMediaV2Row {
@Prop({ type: Number })
id?: number;
@Prop({ type: String })
name?: string;
@Prop({ type: String })
side?: string;
@Prop({ type: String })
label_fa?: string;
@Prop({ type: String })
catalogKey?: string;
@Prop({ type: String })
path?: string;
@Prop({ type: String })
fileName?: string;
@Prop({ type: String })
url?: string;
@Prop({ type: Date })
capturedAt?: Date;
}
export const DamagedPartMediaV2RowSchema =
SchemaFactory.createForClass(DamagedPartMediaV2Row);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimMedia { export class ClaimMedia {
@Prop({ type: CarGreenCardModel }) @Prop({ type: CarGreenCardModel })
@@ -46,15 +79,10 @@ export class ClaimMedia {
carAngles?: Map<string, CapturedImage>; carAngles?: Map<string, CapturedImage>;
/** /**
* V2: Damaged parts captures * V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
* Map of part key to captured image * Legacy documents may store a plain object map keyed by part slug until migrated on write.
*/ */
@Prop({ @Prop({ type: MongooseSchema.Types.Mixed })
type: Map, damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
of: CapturedImageSchema,
default: () => ({}),
})
damagedParts?: Map<string, CapturedImage>;
} }
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia); export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);

View File

@@ -1,6 +1,11 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimActorLock { export class ClaimActorLock {
@@ -12,9 +17,29 @@ export class ClaimActorLock {
@Prop({ type: String, default: "damage_expert" }) @Prop({ type: String, default: "damage_expert" })
actorRole: "damage_expert" | "expert" | "admin"; actorRole: "damage_expert" | "expert" | "admin";
/** Damage expert profile when lock was taken (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock); export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
@Schema({ _id: false })
export class ClaimPreLockQueueSnapshot {
@Prop({ type: String, enum: ClaimStatus, required: true })
claimStatus: ClaimStatus;
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
currentStep: ClaimWorkflowStep;
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
nextStep?: ClaimWorkflowStep;
}
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
ClaimPreLockQueueSnapshot,
);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimWorkflow { export class ClaimWorkflow {
@Prop({ type: String, enum: ClaimWorkflowStep }) @Prop({ type: String, enum: ClaimWorkflowStep })
@@ -32,8 +57,15 @@ export class ClaimWorkflow {
@Prop({ type: Date }) @Prop({ type: Date })
lockedAt?: Date; lockedAt?: Date;
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
@Prop({ type: Date })
expiredAt?: Date;
@Prop({ type: ClaimActorLockSchema }) @Prop({ type: ClaimActorLockSchema })
lockedBy?: ClaimActorLock; lockedBy?: ClaimActorLock;
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
} }
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -26,6 +26,7 @@ import {
ClaimCaseSnapshotSchema, ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema"; } from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema"; import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class RequiredDocumentRef { export class RequiredDocumentRef {
@@ -75,6 +76,12 @@ export class ClaimCase {
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING }) @Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus; claimStatus: ClaimStatus;
/**
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
*/
@Prop({ default: false })
blameDocumentResendPending?: boolean;
/** /**
* Link to the blame case that originated this claim. * Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file). * IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
@@ -133,6 +140,15 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] }) @Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[]; history?: HistoryEvent[];
/**
* User satisfaction rating (damaged party), after the case is completed.
*/
@Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating;
@Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId;
/** /**
* Legacy fields kept optional to simplify progressive migration. * Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these. * If you choose to migrate later, we can remove these.

View File

@@ -122,6 +122,10 @@ export class SubmitReply {
@Prop() @Prop()
userComment?: UserComment; userComment?: UserComment;
/** Damage expert profile at reply time (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ActorLockDetails { export class ActorLockDetails {
@@ -130,6 +134,10 @@ export class ActorLockDetails {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
actorId?: Types.ObjectId; actorId?: Types.ObjectId;
/** Damage expert profile when lock was taken (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ResendCarPartsDto { export class ResendCarPartsDto {
@@ -149,6 +157,10 @@ export class ClaimSubmitResend {
@Prop({ required: true }) @Prop({ required: true })
resendCarParts: ResendCarPartsDto[]; resendCarParts: ResendCarPartsDto[];
/** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class UserResendDocuments { export class UserResendDocuments {
@@ -360,6 +372,14 @@ export class ClaimRequestManagementModel {
@Prop({ type: UserClaimRating, required: false }) @Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating; userRating?: UserClaimRating;
/** Latest damage expert profile when validating repair factors (V1). */
@Prop({ type: Object })
factorValidationExpertProfileSnapshot?: Record<string, string | undefined>;
/** Damage expert profile when in-person visit was requested (V1). */
@Prop({ type: Object })
damageExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
@Prop({ type: String, required: false }) @Prop({ type: String, required: false })
visitLocation?: string; visitLocation?: string;

View File

@@ -0,0 +1,135 @@
import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname } from "path";
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
import { ClaimRequestManagementService } from "./claim-request-management.service";
@ApiTags("registrar-claim (v1)")
@Controller("registrar-claim")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.REGISTRAR)
export class RegistrarClaimV1Controller {
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
@Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" })
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
blameRequestId,
registrar,
);
}
@Get("request/:claimRequestId")
getClaim(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
return this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId,
registrar.sub,
registrar,
);
}
@Patch("select-outer-parts/:claimRequestId")
@ApiBody({ type: SelectOuterPartsV2Dto })
selectOuter(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() registrar: any,
) {
return this.claimRequestManagementService.selectOuterPartsV2(
claimRequestId,
body,
registrar.sub,
registrar,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiBody({ type: SelectOtherPartsV2Dto })
selectOther(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() registrar: any,
) {
return this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
registrar.sub,
registrar,
);
}
@Get("capture-requirements/:claimRequestId")
getCapture(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
return this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
registrar.sub,
registrar,
);
}
@Post("upload-document/:claimRequestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
}),
}),
)
@ApiOperation({ summary: "Registrar uploads required claim document" })
uploadDoc(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() registrar: any,
) {
return this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
registrar.sub,
registrar,
);
}
@Post("capture-part/:claimRequestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
}),
}),
)
capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() registrar: any,
) {
return this.claimRequestManagementService.capturePartV2(
claimRequestId,
body,
file,
registrar.sub,
registrar,
);
}
}

View File

@@ -7,25 +7,27 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
import { ClientDto } from "./dto/create-client.dto"; import { ClientDto } from "./dto/create-client.dto";
@Controller("client") @Controller("client")
@ApiTags("client-management") @ApiTags("client-management")
@ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.ADMIN)
export class ClientController { export class ClientController {
constructor(private readonly clientService: ClientService) {} constructor(private readonly clientService: ClientService) {}
@Post() @Post()
@UseGuards(GlobalGuard)
@ApiBearerAuth()
async addClient(@Body() client: ClientDto) { async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client); return await this.clientService.addClient(client);
} }
@Get() @Get()
@ApiBearerAuth()
@UseGuards(GlobalGuard)
async getClient(@CurrentUser() user) { async getClient(@CurrentUser() user) {
return await this.clientService.getClients(); return await this.clientService.getClients();
} }

View File

@@ -1,5 +1,11 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsNotEmpty, IsOptional } from "class-validator"; import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from "class-validator";
export class CreateBranchDto { export class CreateBranchDto {
@ApiProperty({ example: "شهرک غرب" }) @ApiProperty({ example: "شهرک غرب" })
@@ -31,4 +37,18 @@ export class CreateBranchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
phoneNumber?: string; phoneNumber?: string;
@ApiProperty({ required: false, example: true, default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
required: false,
example: "2025-01-01T00:00:00.000Z",
description: "Branch activity start datetime (ISO string)",
})
@IsOptional()
@IsDateString()
activityStartDate?: string;
} }

View File

@@ -1,31 +1,44 @@
import { Injectable } from "@nestjs/common";
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { Types } from "mongoose"; import { Types } from "mongoose";
class ClientName { class ClientName {
@ApiProperty({}) @ApiProperty({})
@IsString()
@IsNotEmpty()
persian: string; persian: string;
@ApiProperty({}) @ApiProperty({})
@IsString()
@IsNotEmpty()
english: string; english: string;
} }
class Property { class Property {
@ApiProperty({}) @ApiProperty({})
@IsString()
@IsNotEmpty()
smsApiKey: string; smsApiKey: string;
} }
@Injectable()
export class ClientDto { export class ClientDto {
@ApiProperty({ required: true }) @ApiProperty({ required: true })
@ValidateNested()
@Type(() => ClientName)
clientName: ClientName; clientName: ClientName;
@ApiProperty({ required: true }) @ApiProperty({ required: true })
@IsNumber()
clientCode: number; clientCode: number;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
property: Property; @IsOptional()
@ValidateNested()
@Type(() => Property)
property?: Property;
@ApiProperty({ examples: ["legal", "genuine"] }) @ApiProperty({ examples: ["legal", "genuine"] })
@IsIn(["legal", "genuine"])
useExpertMode: "legal" | "genuine"; useExpertMode: "legal" | "genuine";
} }
export class ClientDtoRs { export class ClientDtoRs {
@@ -35,6 +48,9 @@ export class ClientDtoRs {
useExpertsMode: string; useExpertsMode: string;
constructor(readonly client) { constructor(readonly client) {
this.persian = client.clientName.persian; this.persian = client.clientName.persian;
this.english = client.clientName.english;
this.clientId = client._id;
this.useExpertsMode = client.useExpertMode;
} }
} }

View File

@@ -28,6 +28,70 @@ export class BranchDbService {
}); });
} }
async findAllWithFilters(
insuranceId: string,
opts?: {
search?: string;
from?: Date;
to?: Date;
isActive?: boolean;
},
): Promise<BranchModel[]> {
const filter: any = { clientKey: new Types.ObjectId(insuranceId) };
if (typeof opts?.isActive === "boolean") {
filter.isActive = opts.isActive;
}
if (opts?.from || opts?.to) {
filter.$or = [
{
createdAt: {
...(opts.from ? { $gte: opts.from } : {}),
...(opts.to ? { $lte: opts.to } : {}),
},
},
{
activityStartDate: {
...(opts.from ? { $gte: opts.from } : {}),
...(opts.to ? { $lte: opts.to } : {}),
},
},
];
}
if (opts?.search?.trim()) {
const q = opts.search.trim();
const rx = new RegExp(q, "i");
filter.$and = [
...(filter.$and || []),
{
$or: [
{ name: rx },
{ code: rx },
{ city: rx },
{ state: rx },
{ address: rx },
{ phoneNumber: rx },
],
},
];
}
return this.branchModel.find(filter).lean();
}
async findByIds(ids: Types.ObjectId[]) {
return this.branchModel
.find({ _id: { $in: ids } })
.select({ _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, isActive: 1, activityStartDate: 1, createdAt: 1, updatedAt: 1 })
.lean();
}
async findByIdAndUpdate(id: string, update: any): Promise<BranchModel | null> {
return this.branchModel.findByIdAndUpdate(id, update, { new: true }).lean();
}
async findById(id: string): Promise<BranchModel | null> { async findById(id: string): Promise<BranchModel | null> {
return this.branchModel.findById(id).lean(); return this.branchModel.findById(id).lean();
} }

View File

@@ -25,6 +25,12 @@ export class BranchModel {
@Prop() @Prop()
phoneNumber?: string; phoneNumber?: string;
@Prop({ type: Boolean, default: true })
isActive?: boolean;
@Prop({ type: Date })
activityStartDate?: Date;
} }
export const BranchSchema = SchemaFactory.createForClass(BranchModel); export const BranchSchema = SchemaFactory.createForClass(BranchModel);

View File

@@ -1,8 +0,0 @@
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
export const CustomHeader = createParamDecorator(
(data, ctx: ExecutionContext) => {
console.log(ctx.switchToHttp().getRequest());
console.log(ctx.getType());
},
);

View File

@@ -117,6 +117,7 @@ export class AllRequestDtoV2 {
type: string; type: string;
blameStatus: string; blameStatus: string;
partiesInitialForms: { firstParty: string; secondParty: string }; partiesInitialForms: { firstParty: string; secondParty: string };
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
} }
export class AllRequestDtoRsV2 { export class AllRequestDtoRsV2 {

View File

@@ -35,9 +35,14 @@ export class ResendRequestDto {
description: "Array of resend requests (can include first party, second party, or both)", description: "Array of resend requests (can include first party, second party, or both)",
example: [ example: [
{ {
partyId: "507f1f77bcf86cd799439011", partyId: "681b3b3fb237e5856429e444",
requestedItems: ["drivingLicense", "carCertificate"], requestedItems: ["drivingLicense"],
description: "Please resend with better quality", description: "DESC TEST1",
},
{
partyId: "68b6ba552a6e897df34e0f90",
requestedItems: ["carCertificate"],
description: "DESC TEST2",
}, },
], ],
}) })

View File

@@ -18,6 +18,7 @@ import {
ApiParam, ApiParam,
ApiProduces, ApiProduces,
ApiTags, ApiTags,
ApiOperation,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Response, Request } from "express"; import { Response, Request } from "express";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
@@ -45,30 +46,35 @@ export class ExpertBlameController {
// TODO role guard for expert fix // TODO role guard for expert fix
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get() @Get()
async findAll(@CurrentUser() actor, @ClientKey() client) { async findAll(@CurrentUser() actor, @ClientKey() client) {
return await this.expertBlameService.findAll(actor); return await this.expertBlameService.findAll(actor);
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get(":id") @Get(":id")
async findOne(@Param("id") id: string, @CurrentUser() actor) { async findOne(@Param("id") id: string, @CurrentUser() actor) {
return await this.expertBlameService.findOne(id, actor.sub); return await this.expertBlameService.findOne(id, actor.sub);
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("lock/:id") @Put("lock/:id")
async lockRequest(@Param("id") id: string, @CurrentUser() actor) { async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
return await this.expertBlameService.lockRequest(id, actor); return await this.expertBlameService.lockRequest(id, actor);
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get("request/accident-fields") @Get("request/accident-fields")
async getAccidentFields() { async getAccidentFields() {
return await this.expertBlameService.getAccidentField(); return await this.expertBlameService.getAccidentField();
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/submit/:id") @Put("reply/submit/:id")
@ApiBody({ type: SubmitReplyDto }) @ApiBody({ type: SubmitReplyDto })
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@@ -95,6 +101,7 @@ export class ExpertBlameController {
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/resend/first/:id") @Put("reply/resend/first/:id")
@ApiBody({ type: ResendFirstPartyDto }) @ApiBody({ type: ResendFirstPartyDto })
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@@ -107,6 +114,7 @@ export class ExpertBlameController {
return this.handleResendRequest(id, body, actor.sub, req); return this.handleResendRequest(id, body, actor.sub, req);
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/resend/second/:id") @Put("reply/resend/second/:id")
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiBody({ type: ResendSecondPartyDto }) @ApiBody({ type: ResendSecondPartyDto })
@@ -120,6 +128,7 @@ export class ExpertBlameController {
return this.handleResendRequest(id, body, actor.sub, req); return this.handleResendRequest(id, body, actor.sub, req);
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get("stream/:requestId") @Get("stream/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
async streamVideo(@Param("requestId") requestId: string) { async streamVideo(@Param("requestId") requestId: string) {
@@ -127,6 +136,7 @@ export class ExpertBlameController {
} }
@UseInterceptors(LoggingInterceptor) @UseInterceptors(LoggingInterceptor)
@ApiOperation({ deprecated: true })
@Get("voice/:requestId/:voiceId") @Get("voice/:requestId/:voiceId")
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@@ -149,6 +159,7 @@ export class ExpertBlameController {
} }
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit") @Patch(":id/visit")
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) { async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertBlameService.inPersonVisit(requestId, actor); return await this.expertBlameService.inPersonVisit(requestId, actor);

View File

@@ -6,9 +6,16 @@ import { ExpertBlameController } from "./expert-blame.controller";
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller"; import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({ @Module({
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule], imports: [
UsersModule,
ClientModule,
RequestManagementModule,
PlatesModule,
SmsOrchestrationModule,
],
controllers: [ExpertBlameController, ExpertBlameV2Controller], controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService], providers: [ExpertBlameService],
exports: [ExpertBlameService], exports: [ExpertBlameService],

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import {
Put, Put,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
@@ -22,9 +22,9 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame") @Controller("v2/expert-blame")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller { export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) {} constructor(private readonly expertBlameService: ExpertBlameService) { }
@Get() @Get()
async findAll(@CurrentUser() actor: any) { async findAll(@CurrentUser() actor: any) {
@@ -38,11 +38,28 @@ export class ExpertBlameV2Controller {
} }
} }
@Get("report/status-counts")
@ApiOperation({
summary: "Count blame cases by grouped status bucket (tenant)",
description:
"IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.",
})
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
try {
return await this.expertBlameService.getStatusReportBucketsV2(actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to load blame status report",
);
}
}
@Get(":id") @Get(":id")
@ApiParam({ name: "id", description: "Blame case request id" }) @ApiParam({ name: "id", description: "Blame case request id" })
async findOne(@Param("id") id: string, @CurrentUser() actor: any) { async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
try { try {
return await this.expertBlameService.findOneV2(id, actor.sub); return await this.expertBlameService.findOneV2(id, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -73,7 +90,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any, @CurrentUser() actor: any,
) { ) {
try { try {
return await this.expertBlameService.resendRequestV2(id, body, actor.sub); return await this.expertBlameService.resendRequestV2(id, body, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -93,7 +110,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any, @CurrentUser() actor: any,
) { ) {
try { try {
return await this.expertBlameService.replyRequestV2(id, body, actor.sub); return await this.expertBlameService.replyRequestV2(id, body, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -101,4 +118,22 @@ export class ExpertBlameV2Controller {
); );
} }
} }
@Put("reply/inPerson/:id")
@ApiParam({ name: "id", description: "Blame case request id" })
@ApiBody({ type: SubmitReplyDto })
async submitInPerson(
@Param("id") id: string,
@Body() body: SubmitReplyDto,
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.submitInPerson(id, actor.sub);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply for in person",
);
}
}
} }

View File

@@ -1,4 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto';
export class ClaimDetailV2ResponseDto { export class ClaimDetailV2ResponseDto {
@ApiProperty() @ApiProperty()
@@ -27,6 +29,7 @@ export class ClaimDetailV2ResponseDto {
actorId: string; actorId: string;
actorName: string; actorName: string;
lockedAt: string; lockedAt: string;
expiredAt?: string;
}; };
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -43,14 +46,30 @@ export class ClaimDetailV2ResponseDto {
plate?: any; plate?: any;
}; };
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' }) @ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
blameRequestId?: string; blameRequestId?: string;
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' }) @ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
blameRequestNo?: string; blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Selected outer damaged parts' }) @ApiPropertyOptional({
selectedParts?: string[]; description: 'Selected outer damaged parts (id, name, side, label_fa)',
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[]; otherParts?: string[];
@@ -66,8 +85,109 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({
damagedParts?: Record<string, { captured: boolean; url?: string }>; description:
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)',
type: 'array',
items: {
type: 'object',
properties: {
index: { type: 'number' },
id: { type: 'number', nullable: true },
name: { type: 'string' },
side: { type: 'string' },
label_fa: { type: 'string' },
captured: { type: 'boolean' },
url: { type: 'string' },
},
},
})
damagedParts?: Array<{
index: number;
id?: number | null;
name: string;
side: string;
label_fa: string;
captured: boolean;
url?: string;
}>;
@ApiPropertyOptional({
description:
'True when user uploaded all required factors and the case awaits expert approve/reject.',
})
awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
'Slice of `claim.evaluation` exposed to the damage expert. ' +
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' +
'awaiting factor validation. `ownerInsurerApproval` and ' +
'`ownerPricedPartsApproval` are returned whenever they exist on the ' +
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' +
'front-end can render the user signature directly. Likewise, ' +
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' +
'expose `signLink` when a user comment signature is present.',
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
ownerInsurerApproval?: {
agree: boolean;
branchId?: string;
signDetailId?: string;
signLink?: string;
signedAt?: Date | string;
};
ownerPricedPartsApproval?: {
agree: boolean;
branchId?: string;
signDetailId?: string;
signLink?: string;
signedAt?: Date | string;
};
};
@ApiPropertyOptional({
description:
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
})
objection?: {
objectionParts?: Array<Record<string, unknown>>;
newParts?: Array<Record<string, unknown>>;
submittedAt?: Date | string;
};
@ApiPropertyOptional({
description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
})
videoCapture?: {
id: string;
url?: string;
path?: string;
fileName?: string;
};
@ApiPropertyOptional({
description:
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
})
blameCase?: Record<string, unknown>;
@ApiPropertyOptional({
description:
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
})
blameExpertDecision?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
})
money?: {
sheba?: string;
nationalCodeOfInsurer?: string;
};
@ApiProperty() @ApiProperty()
createdAt: string; createdAt: string;

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
export class ClaimListItemV2Dto { export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID' }) @ApiProperty({ description: 'Claim case ID' })
@@ -7,7 +8,11 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' }) @ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
publicId: string; publicId: string;
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' }) @ApiProperty({
description:
"ClaimCaseStatus (expert list may normalize EXPERT_REVIEWING → WAITING_FOR_DAMAGE_EXPERT). Post-expert owner phase: INSURER_REVIEW_* / OWNER_REPAIR_FACTOR_UPLOAD_PENDING / EXPERT_VALIDATING_REPAIR_FACTORS; see expert reply & validate-factors docs.",
example: "WAITING_FOR_DAMAGE_EXPERT",
})
status: string; status: string;
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' }) @ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
@@ -16,10 +21,12 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false }) @ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
locked: boolean; locked: boolean;
@ApiPropertyOptional({ description: 'Actor who locked this claim' }) @ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
lockedBy?: { lockedBy?: {
actorId: string; actorId: string;
actorName: string; actorName: string;
lockedAt?: string;
expiredAt?: string;
}; };
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' }) @ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
@@ -29,8 +36,28 @@ export class ClaimListItemV2Dto {
carType?: string; carType?: string;
}; };
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
example: BlameRequestType.THIRD_PARTY,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' }) @ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
createdAt: string; createdAt: string;
@ApiPropertyOptional({
description:
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
})
awaitingFactorValidation?: boolean;
} }
export class GetClaimListV2ResponseDto { export class GetClaimListV2ResponseDto {

View File

@@ -6,17 +6,83 @@ import {
IsBoolean, IsBoolean,
IsOptional, IsOptional,
ValidateNested, ValidateNested,
IsEnum,
IsInt,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
export class CarPartDamageV2Dto { /**
@ApiProperty({ example: 'left' }) * Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
@IsString() * Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
side: string; * (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`.
*/
export class ExpertReplyCarPartDamageV2Dto {
@ApiPropertyOptional({ description: 'Catalog id when known' })
@IsOptional()
@IsInt()
id?: number;
@ApiProperty({ example: 'front_door' }) @ApiPropertyOptional({
description: 'Side-agnostic catalog segment, e.g. backfender',
example: 'backfender',
})
@IsOptional()
@IsString() @IsString()
part: string; name?: string;
@ApiPropertyOptional({ example: 'left' })
@IsOptional()
@IsString()
side?: string;
@ApiPropertyOptional({
description:
'Farsi label; optional when server can resolve from catalog + vehicle.carType',
})
@IsOptional()
@IsString()
label_fa?: string;
@ApiPropertyOptional({ example: 'left_backfender' })
@IsOptional()
@IsString()
catalogKey?: string;
@ApiPropertyOptional({
description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender',
example: 'backFender',
})
@IsOptional()
@IsString()
part?: string;
}
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
export class DaghiDetailsV2Dto {
@ApiProperty({
enum: DaghiOption,
description:
'Daghi option: ارزش لوازم بازیافتی, تحویل داغی, فاقد ارزش, با احتساب داغی',
})
@IsEnum(DaghiOption)
option: DaghiOption;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
})
@IsOptional()
@IsString()
price?: string;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
})
@IsOptional()
@IsString()
branchId?: string;
} }
export class PartPricingV2Dto { export class PartPricingV2Dto {
@@ -25,10 +91,10 @@ export class PartPricingV2Dto {
@IsNotEmpty() @IsNotEmpty()
partId: string; partId: string;
@ApiProperty({ type: CarPartDamageV2Dto }) @ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
@ValidateNested() @ValidateNested()
@Type(() => CarPartDamageV2Dto) @Type(() => ExpertReplyCarPartDamageV2Dto)
carPartDamage: CarPartDamageV2Dto; carPartDamage: ExpertReplyCarPartDamageV2Dto;
@ApiProperty({ example: 'Minor' }) @ApiProperty({ example: 'Minor' })
@IsString() @IsString()
@@ -46,12 +112,16 @@ export class PartPricingV2Dto {
@IsString() @IsString()
totalPayment: string; totalPayment: string;
@ApiPropertyOptional({ example: '500000' }) @ApiProperty({ type: DaghiDetailsV2Dto })
@IsOptional() @ValidateNested()
@IsString() @Type(() => DaghiDetailsV2Dto)
daghi?: string; daghi: DaghiDetailsV2Dto;
@ApiProperty({ example: false }) @ApiProperty({
example: false,
description:
"If true, the owner must upload a repair factor image for this line; expert confirms cost later (validate-factors). Priced lines omit this or set false.",
})
@IsBoolean() @IsBoolean()
factorNeeded: boolean; factorNeeded: boolean;
} }
@@ -62,9 +132,41 @@ export class SubmitExpertReplyV2Dto {
@IsNotEmpty() @IsNotEmpty()
description: string; description: string;
@ApiProperty({ type: [PartPricingV2Dto] }) @ApiProperty({
type: [PartPricingV2Dto],
description:
"Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.",
})
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => PartPricingV2Dto) @Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[]; parts: PartPricingV2Dto[];
} }
export class ClaimSubmitResendV2Dto {
@ApiPropertyOptional({
description:
"Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.",
})
@IsOptional()
@IsString()
resendDescription?: string;
@ApiPropertyOptional({
type: [String],
enum: ClaimRequiredDocumentType,
description:
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
})
@IsOptional()
@IsArray()
@IsEnum(ClaimRequiredDocumentType, { each: true })
resendDocuments?: ClaimRequiredDocumentType[];
@ApiPropertyOptional({ type: [DamagedPartItem] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DamagedPartItem)
resendCarParts?: DamagedPartItem[];
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import {
IsArray, IsArray,
@@ -31,3 +31,45 @@ export class FactorValidationDto {
@Type(() => FactorDecisionDto) @Type(() => FactorDecisionDto)
decisions: FactorDecisionDto[]; decisions: FactorDecisionDto[];
} }
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
class FactorDecisionV2Dto {
@ApiProperty()
@IsString()
partId: string;
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
status: FactorStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
rejectionReason?: string;
@ApiPropertyOptional({
description:
"Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).",
})
@IsOptional()
@IsString()
price?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
salary?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
totalPayment?: string;
}
export class FactorValidationV2Dto {
@ApiProperty({ type: [FactorDecisionV2Dto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FactorDecisionV2Dto)
decisions: FactorDecisionV2Dto[];
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import {
ArrayMinSize,
IsArray,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
export class UpdateClaimDamagedPartsV2Dto {
@ApiProperty({
type: [DamageSelectedPartV2BodyDto],
description: "Full replacement list of selected damaged outer parts (id, name, side, label_fa)",
})
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => DamageSelectedPartV2BodyDto)
selectedParts: DamageSelectedPartV2BodyDto[];
}

View File

@@ -17,6 +17,7 @@ import {
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiTags, ApiTags,
ApiOperation,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
export class ExpertClaimController { export class ExpertClaimController {
constructor(private readonly expertClaimService: ExpertClaimService) {} constructor(private readonly expertClaimService: ExpertClaimService) {}
@ApiOperation({ deprecated: true })
@Get() @Get()
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) { async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
return await this.expertClaimService.getClaimRequestsListForExpert(actor); return await this.expertClaimService.getClaimRequestsListForExpert(actor);
} }
@ApiOperation({ deprecated: true })
@Get("/:id") @Get("/:id")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@ApiQuery({ @ApiQuery({
@@ -64,12 +67,14 @@ export class ExpertClaimController {
); );
} }
@ApiOperation({ deprecated: true })
@Get("imageRequired/:id") @Get("imageRequired/:id")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) { async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
return await this.expertClaimService.imageRequired(id, currentUser); return await this.expertClaimService.imageRequired(id, currentUser);
} }
@ApiOperation({ deprecated: true })
@Get("required-documents/:id") @Get("required-documents/:id")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async getRequiredDocuments( async getRequiredDocuments(
@@ -79,11 +84,13 @@ export class ExpertClaimController {
return await this.expertClaimService.getRequiredDocuments(id, currentUser); return await this.expertClaimService.getRequiredDocuments(id, currentUser);
} }
@ApiOperation({ deprecated: true })
@Put("lock/:id") @Put("lock/:id")
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) { async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertClaimService.lockClaimRequest(requestId, actor); return await this.expertClaimService.lockClaimRequest(requestId, actor);
} }
@ApiOperation({ deprecated: true })
@Put("reply/submit/:id") @Put("reply/submit/:id")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async submitReplyRequest( async submitReplyRequest(
@@ -98,6 +105,7 @@ export class ExpertClaimController {
); );
} }
@ApiOperation({ deprecated: true })
@Put("resend/submit/:id") @Put("resend/submit/:id")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async submitResend( async submitResend(
@@ -117,6 +125,7 @@ export class ExpertClaimController {
enum: ["car-capture", "accident"], enum: ["car-capture", "accident"],
required: true, required: true,
}) })
@ApiOperation({ deprecated: true })
@Get("stream/:id/video") @Get("stream/:id/video")
@Header("Accept-Ranges", "bytes") @Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4") @Header("Content-Type", "video/mp4")
@@ -134,6 +143,7 @@ export class ExpertClaimController {
enum: ["car-capture", "accident"], enum: ["car-capture", "accident"],
required: true, required: true,
}) })
@ApiOperation({ deprecated: true })
@Get("link/:id/video") @Get("link/:id/video")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async getClaimVideoLink( async getClaimVideoLink(
@@ -143,12 +153,14 @@ export class ExpertClaimController {
return this.expertClaimService.getVideoLink(id, query); return this.expertClaimService.getVideoLink(id, query);
} }
@ApiOperation({ deprecated: true })
@Get("stream/:id/voice") @Get("stream/:id/voice")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async getClaimVoiceLink(@Param("id") id: string) { async getClaimVoiceLink(@Param("id") id: string) {
return await this.expertClaimService.getVoiceLink(id); return await this.expertClaimService.getVoiceLink(id);
} }
@ApiOperation({ deprecated: true })
@Patch("validate-factors/:claimId") @Patch("validate-factors/:claimId")
@ApiParam({ name: "claimId" }) @ApiParam({ name: "claimId" })
@ApiBody({ type: FactorValidationDto }) @ApiBody({ type: FactorValidationDto })
@@ -166,11 +178,13 @@ export class ExpertClaimController {
} }
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit") @Patch(":id/visit")
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) { async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertClaimService.inPersonVisit(requestId, actor); return await this.expertClaimService.inPersonVisit(requestId, actor);
} }
@ApiOperation({ deprecated: true })
@Get("branches/:insuranceId") @Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" }) @ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {

View File

@@ -13,6 +13,7 @@ import { ClientModule } from "src/client/client.module";
import { ExpertBlameModule } from "src/expert-blame/expert-blame.module"; import { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ExpertClaimController } from "./expert-claim.controller"; import { ExpertClaimController } from "./expert-claim.controller";
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller"; import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
ExpertBlameModule, ExpertBlameModule,
AiModule, AiModule,
RequestManagementModule, RequestManagementModule,
SmsOrchestrationModule,
UsersModule, UsersModule,
ClientModule, ClientModule,
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiPropertyOptional, ApiPropertyOptional,
ApiQuery,
ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator"; import { IsOptional, IsString } from "class-validator";
@@ -14,7 +16,12 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
class InPersonVisitV2Dto { class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@@ -29,40 +36,73 @@ class InPersonVisitV2Dto {
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.DAMAGE_EXPERT)
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) {} constructor(
private readonly expertClaimService: ExpertClaimService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("report/status-counts")
@ApiOperation({
summary: "Count claim cases by grouped status bucket (tenant)",
description:
"IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.",
})
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
return await this.expertClaimService.getStatusReportBucketsV2(actor);
}
/**
* Same catalog as `GET v2/claim-request-management/outer-parts-catalog` so factor/resend payloads use identical part ids.
*/
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(
@Query("carType") carType?: ClaimVehicleTypeV2,
): Promise<OuterPartCatalogItemDto[]> {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("requests") @Get("requests")
@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, plus this expert's own locked/in-progress claims.", "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 (`status=EXPERT_VALIDATING_REPAIR_FACTORS` or legacy `WAITING_FOR_INSURER_APPROVAL` with UNDER_REVIEW at EXPERT_COST_EVALUATION).",
}) })
async getClaimListV2(@CurrentUser() actor) { async getClaimListV2(@CurrentUser() actor) {
return await this.expertClaimService.getClaimListV2(actor.sub); return await this.expertClaimService.getClaimListV2(actor);
} }
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Get claim request detail for damage expert", summary: "Get claim request detail for damage expert",
description: description:
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.", "Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async getClaimDetailV2( async getClaimDetailV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.getClaimDetailV2( return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
claimRequestId,
actor.sub,
);
} }
@Put("lock/:claimRequestId") @Put("lock/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Lock a claim request for review", summary: "Lock a claim request for review",
description: description:
"Claim must have status WAITING_FOR_DAMAGE_EXPERT. Locking sets status to EXPERT_REVIEWING and claimStatus to UNDER_REVIEW. Only one expert can lock a claim at a time.", "Lockable in two queues:\n" +
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2( async lockClaimRequestV2(
@@ -76,7 +116,13 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: description:
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of `totalPayment` ≤ 30,000,000. Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto }) @ApiBody({ type: SubmitExpertReplyV2Dto })
@@ -88,6 +134,24 @@ export class ExpertClaimV2Controller {
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor); return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
} }
@Put("reply/resend/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage resend request",
description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto })
async submitExpertResendV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: ClaimSubmitResendV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
}
@Patch(":claimRequestId/visit") @Patch(":claimRequestId/visit")
@ApiOperation({ @ApiOperation({
summary: "Request in-person visit for claim", summary: "Request in-person visit for claim",
@@ -107,4 +171,66 @@ export class ExpertClaimV2Controller {
body?.note, body?.note,
); );
} }
@Patch("validate-factors/:claimRequestId")
@ApiOperation({
summary: "Validate uploaded repair factors (V2 ClaimCase)",
description:
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`; rejected lines require expert `totalPayment` (or both `price` and `salary`).\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `status=INSURER_REVIEW_AWAITING_OWNER_SIGN`, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW` → owner must **sign or reject** via `owner-insurer-approval/sign`; agree completes the case.\n" +
"- **Any rejected (repriced):** same as above (`INSURER_REVIEW_AWAITING_OWNER_SIGN` + `INSURER_REVIEW`) → owner must **accept or reject** the repriced totals.\n" +
"- **Partial batch:** service may return pending until every factor line has a non-pending decision.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FactorValidationV2Dto })
async validateClaimFactorsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FactorValidationV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.validateClaimFactorsV2(
claimRequestId,
body,
actor,
);
}
@Patch("request/:claimRequestId/damaged-parts")
@ApiOperation({
summary: "Edit selected damaged parts (V2)",
description:
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
async updateClaimDamagedPartsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UpdateClaimDamagedPartsV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.updateClaimDamagedPartsV2(
claimRequestId,
body,
actor,
);
}
@ApiQuery({
name: "query",
enum: ["car-capture", "accident"],
required: true,
})
@Get("stream/:id/video")
@Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4")
async claimStream(
@Headers() headers,
@Param("id") id: string,
@Query("query") query: "car-capture" | "accident",
@Res() res,
) {
return this.expertClaimService.streamServiceV2(id, query, res, headers);
}
} }

View File

@@ -0,0 +1,83 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
export class CreateInsurerExpertDto {
@ApiProperty({ example: "Ali" })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: "Ahmadi" })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: "1234567890" })
@IsString()
@IsNotEmpty()
nationalCode: string;
@ApiProperty({ example: "expert@example.com" })
@IsEmail()
email: string;
@ApiProperty({ example: "StrongPass!123" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ example: "09121234567" })
@IsString()
@IsNotEmpty()
mobile: string;
@ApiProperty({
example: "665f0e5ab74d670939b08920",
description: "Branch id this expert belongs to",
})
@IsMongoId()
branchId: string;
@ApiPropertyOptional({ enum: UserType, example: UserType.LEGAL })
@IsOptional()
@IsEnum(UserType)
userType?: UserType;
@ApiPropertyOptional({ example: "02112345678" })
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({ example: "Tehran" })
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ example: "Tehran" })
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional({ example: "No. 12, Sample St." })
@IsOptional()
@IsString()
address?: string;
}
export class CreateBlameExpertByInsurerDto extends CreateInsurerExpertDto {
@ApiPropertyOptional({
enum: [RoleEnum.EXPERT],
default: RoleEnum.EXPERT,
})
role?: RoleEnum.EXPERT;
}
export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
@ApiPropertyOptional({
enum: [RoleEnum.DAMAGE_EXPERT],
default: RoleEnum.DAMAGE_EXPERT,
})
role?: RoleEnum.DAMAGE_EXPERT;
}

View File

@@ -13,6 +13,7 @@ import {
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation,
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiTags, ApiTags,
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertInsurerService } from "./expert-insurer.service"; import { ExpertInsurerService } from "./expert-insurer.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
} from "./dto/create-insurer-expert.dto";
@Controller("expert-insurer") @Controller("expert-insurer")
@ApiTags("expert-insurer-panel") @ApiTags("expert-insurer-panel")
@@ -35,16 +40,105 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
export class ExpertInsurerController { export class ExpertInsurerController {
constructor(private readonly expertInsurerService: ExpertInsurerService) {} constructor(private readonly expertInsurerService: ExpertInsurerService) {}
@Get("files") @Get("branches")
async getAllFiles(@CurrentUser() insurer) { @ApiQuery({ name: "search", required: false, type: String })
return await this.expertInsurerService.retrieveAllFilesOfClient( @ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
@ApiQuery({
name: "isActive",
required: false,
description: "Filter active state (true/false)",
})
async getInsuranceBranches(
@CurrentUser() insurer,
@Query("search") search?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey, insurer.clientKey,
{ search, from, to, isActive },
); );
} }
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.addBranch(
insurer.clientKey,
createBranchDto,
);
}
@Put("branches/:branchId/status")
@ApiParam({ name: "branchId" })
@ApiQuery({ name: "isActive", type: Boolean })
async setBranchStatus(
@CurrentUser() insurer,
@Param("branchId") branchId: string,
@Query("isActive") isActive: string,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
const normalized = String(isActive).trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be true/false");
}
const active = ["true", "1", "yes"].includes(normalized);
return this.expertInsurerService.setBranchActive(
insurer.clientKey,
branchId,
active,
);
}
@Post("experts/blame")
@ApiBody({ type: CreateBlameExpertByInsurerDto })
async addBlameExpert(
@CurrentUser() insurer,
@Body() body: CreateBlameExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
}
@Post("experts/claim")
@ApiBody({ type: CreateClaimExpertByInsurerDto })
async addClaimExpert(
@CurrentUser() insurer,
@Body() body: CreateClaimExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
}
@ApiQuery({ name: "page", type: Number }) @ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number }) @ApiQuery({ name: "response_count", type: Number })
@Get("list") @Get("experts/list")
async getAllExperts( async getAllExperts(
@Query("page") page: number, @Query("page") page: number,
@Query("response_count") count: number, @Query("response_count") count: number,
@@ -57,43 +151,38 @@ export class ExpertInsurerController {
); );
} }
@Get("top-experts") @Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
description:
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
})
async getTopExperts(@CurrentUser() actor) { async getTopExperts(@CurrentUser() actor) {
return await this.expertInsurerService.getTopExpertsForClient(actor); return await this.expertInsurerService.getTopExpertsForClient(actor);
} }
@Get("top-files") @Get("files")
async getTopFiles(@CurrentUser() insurer) { async getAllFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient( return await this.expertInsurerService.retrieveAllFilesOfClient(
insurer.clientKey, insurer.clientKey,
); );
} }
@Get("statistics") @Get("files/:publicId")
async getExpertStatistics(@CurrentUser() actor) { @ApiParam({ name: "publicId" })
return await this.expertInsurerService.getExpertStatisticsReport(actor); async getFileDetailsByPublicId(
} @CurrentUser() insurer,
@Param("publicId") publicId: string,
@ApiParam({ name: "expertId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId")
async requestDetail(
@Param("expertId") id: string,
@Query("role") role: "claim" | "blame",
) { ) {
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) { return await this.expertInsurerService.retrieveFileDetailsByPublicId(
throw new BadRequestException("Invalid expert ID"); insurer.clientKey,
} publicId,
);
if (!["claim", "blame"].includes(role)) {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
} }
@ApiBody({ @ApiBody({
description: "Detailed expert rating", description:
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
@@ -130,7 +219,7 @@ export class ExpertInsurerController {
minimum: 0, minimum: 0,
maximum: 5, maximum: 5,
example: 4, example: 4,
description: "برای امتیاز دادن به بات", description: "برای امتیاز دادن به عملکرد بات",
}, },
}, },
required: [ required: [
@@ -149,47 +238,70 @@ export class ExpertInsurerController {
}, },
}, },
}) })
@ApiParam({ name: "requestId" }) @ApiParam({ name: "publicId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Put("files/:publicId/rating")
@Put("/:requestId/rating") async rateExpertsByPublicId(
async rateExperts(
@Param("requestId") requestId: string,
@Body() rating: FileRating,
@Query("role") role: "claim" | "blame",
) {
if (!["claim", "blame"].includes(role)) {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.rateExpertOnFile(
requestId,
rating,
role,
);
}
@Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
console.log("insuranceId", insuranceId);
return await this.expertInsurerService.retrieveInsuranceBranches(
insuranceId,
);
}
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer, @CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto, @Param("publicId") publicId: string,
@Body() rating: FileRating,
) { ) {
if (!insurer) { return await this.expertInsurerService.rateExpertByPublicId(
throw new UnauthorizedException("Could not identify the current user."); publicId,
rating,
insurer.clientKey,
);
}
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
}
@Get("report/status-counts")
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
}
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
description:
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
})
@Get("/:expertId")
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
if (!Types.ObjectId.isValid(id)) {
throw new BadRequestException("Invalid expert ID");
} }
return await this.expertInsurerService.addBranch( return await this.expertInsurerService.getAllFilesForInsurerExpert(
id,
insurer.clientKey, insurer.clientKey,
createBranchDto,
); );
} }
} }

View File

@@ -19,12 +19,14 @@ import {
BranchModel, BranchModel,
BranchSchema, BranchSchema,
} from "src/client/entities/schema/branch.schema"; } from "src/client/entities/schema/branch.schema";
import { HashModule } from "src/utils/hash/hash.module";
@Module({ @Module({
imports: [ imports: [
ClaimRequestManagementModule, ClaimRequestManagementModule,
RequestManagementModule, RequestManagementModule,
AuthModule, AuthModule,
HashModule,
UsersModule, UsersModule,
ClientModule, ClientModule,
MongooseModule.forFeature([ MongooseModule.forFeature([

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
export enum PanelModels {
BLAME = "blame",
CLAIM = "claim",
}

View File

@@ -0,0 +1,149 @@
import { Types } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */
export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = {
accidentWay: { id: 8, label: "جلو به عقب" },
accidentReason: {
id: 1,
fanavaran: 1,
label:
"عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو",
},
accidentType: {
id: 1,
label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه",
},
} as const;
export type MutualAgreementDecisionPayload = {
guiltyPartyId: Types.ObjectId;
description: string;
decidedAt: Date;
decidedByExpertId?: Types.ObjectId;
fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS;
};
function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string {
return (p?.person?.phoneNumber || "").trim();
}
function partyUserId(
p: { person?: { userId?: Types.ObjectId } } | undefined,
): Types.ObjectId | null {
const id = p?.person?.userId;
if (!id) return null;
return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id));
}
/**
* When the first party chose AGREED (guilty or damaged) and the second party exists,
* derive the guilty party and build the synthetic expert.decision payload.
* Does not set decidedByExpertId — this is not a field-expert adjudication.
*/
export function buildMutualAgreementExpertDecision(
doc: Record<string, unknown>,
): MutualAgreementDecisionPayload | null {
if (doc.type !== BlameRequestType.THIRD_PARTY) return null;
if (doc.blameStatus !== BlameStatus.AGREED) return null;
const parties = (doc.parties ?? []) as Array<{
role?: string;
person?: { userId?: Types.ObjectId; phoneNumber?: string };
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const first = parties.find((p) => p.role === PartyRole.FIRST);
const second = parties.find((p) => p.role === PartyRole.SECOND);
if (!first || !second) return null;
const st = first.statement;
if (!st) return null;
if (st.acceptsExpertOpinion) return null;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return null;
let guiltyParty: typeof first;
let damagedParty: typeof first;
if (admits) {
guiltyParty = first;
damagedParty = second;
} else {
guiltyParty = second;
damagedParty = first;
}
const guiltyId = partyUserId(guiltyParty);
if (!guiltyId) return null;
const gPhone = partyPhone(guiltyParty);
const dPhone = partyPhone(damagedParty);
if (!gPhone || !dPhone) return null;
const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`;
return {
guiltyPartyId: guiltyId,
description,
decidedAt: new Date(),
fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
};
}
/**
* Merge complementary statement flags onto the second party for expert/claim UIs when
* only the first party submitted a formal confession (V2 remote flow).
*/
export function enrichBlamePartiesForAgreementView(
doc: Record<string, unknown>,
): Array<Record<string, unknown>> {
const parties = (doc.parties ?? []) as Array<Record<string, unknown>>;
if (!Array.isArray(parties) || parties.length === 0) return parties;
const first = parties.find((p) => p.role === PartyRole.FIRST) as
| {
statement?: Record<string, unknown>;
}
| undefined;
const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND);
if (!first?.statement || secondIdx === -1) return parties;
if (doc.blameStatus !== BlameStatus.AGREED) return parties;
const st = first.statement as {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
if (st.acceptsExpertOpinion) return parties;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return parties;
const second = parties[secondIdx] as {
statement?: Record<string, unknown>;
};
const prev = (second.statement ?? {}) as Record<string, unknown>;
const mergedStatement = {
...prev,
admitsGuilt: admits ? false : true,
claimsDamage: admits ? true : false,
acceptsExpertOpinion: false,
};
const next = [...parties];
next[secondIdx] = {
...second,
statement: mergedStatement,
};
return next;
}

View File

@@ -0,0 +1,181 @@
/**
* Car-angle captures must live under {@code media.carAngles} with lowercase keys
* {@code front | back | left | right}. Clients sometimes send Title Case keys or
* mistakenly use {@code captureType: "part"} for angles, which previously wrote
* into {@code media.damagedParts} and broke step-completion checks.
*/
import type { DamageSelectedPartV2 } from "src/helpers/outer-damage-parts";
import {
damageSelectedPartsToLookupStrings,
mediaRowHasCapture,
resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts";
export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
export function canonicalizeClaimCarAngleKey(
raw: string | undefined | null,
): ClaimCarAngleKey | null {
const t = String(raw ?? "").trim().toLowerCase();
return (CLAIM_CAR_ANGLE_KEYS as readonly string[]).includes(t)
? (t as ClaimCarAngleKey)
: null;
}
function getCaptureBlob(data: unknown, key: string): unknown {
if (data == null) return undefined;
if (data instanceof Map) return (data as Map<string, unknown>).get(key);
return (data as Record<string, unknown>)[key];
}
/** Prefer carAngles; fall back to legacy mis-placed blobs under damagedParts. */
export function getClaimCarAngleCaptureBlob(
carAnglesData: unknown,
damagedPartsData: unknown,
angle: ClaimCarAngleKey,
): unknown {
return (
getCaptureBlob(carAnglesData, angle) ??
getCaptureBlob(damagedPartsData, angle) ??
getCaptureBlob(
damagedPartsData,
angle.charAt(0).toUpperCase() + angle.slice(1),
)
);
}
export function hasClaimCarAngleCapture(
carAnglesData: unknown,
damagedPartsData: unknown,
angle: ClaimCarAngleKey,
): boolean {
return getClaimCarAngleCaptureBlob(carAnglesData, damagedPartsData, angle) != null;
}
/**
* Field names under media.damagedParts that should be removed when we persist the
* same shot under media.carAngles (migration). Skips keys that are also real selected part ids.
*/
export function legacyAngleDamagedPartFieldKeysToUnset(
canonical: ClaimCarAngleKey,
rawCaptureKey: string,
selectedParts: unknown,
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): string[] {
const selected = new Set(
damageSelectedPartsToLookupStrings(
selectedParts,
carType,
selectedOuterPartsLegacy,
),
);
const variants = new Set(
[
rawCaptureKey,
canonical,
canonical.charAt(0).toUpperCase() + canonical.slice(1),
].filter(Boolean),
);
const out: string[] = [];
for (const k of variants) {
if (canonicalizeClaimCarAngleKey(k) === null) continue;
if (selected.has(k)) continue;
out.push(k);
}
return [...new Set(out)];
}
/** Same English title derived from snake_case keys as capture-requirements UI (legacy client storage). */
export function legacyDamagedPartTitleEnFromPartKey(partKey: string): string {
return String(partKey)
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function normalizeDamagedPartKeyLoose(s: string): string {
return String(s).toLowerCase().replace(/[\s_-]/g, "");
}
export function getDamagedPartCaptureBlob(
damagedPartsData: unknown,
partKey: string,
selectedNormalized?: DamageSelectedPartV2[],
): unknown {
const pk = String(partKey ?? "").trim();
if (!pk || damagedPartsData == null) return undefined;
if (Array.isArray(damagedPartsData)) {
const norm = selectedNormalized ?? [];
const idx = resolvePartCaptureIndex(
pk,
norm,
damagedPartsData as Record<string, unknown>[],
);
if (idx >= 0) {
const row = (damagedPartsData as unknown[])[idx];
if (mediaRowHasCapture(row)) return row;
}
return undefined;
}
let b = getCaptureBlob(damagedPartsData, pk);
if (b) return b;
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
if (titleEn !== pk) {
b = getCaptureBlob(damagedPartsData, titleEn);
if (b) return b;
}
const target = normalizeDamagedPartKeyLoose(pk);
const keys =
damagedPartsData instanceof Map
? Array.from((damagedPartsData as Map<string, unknown>).keys())
: Object.keys(damagedPartsData as Record<string, unknown>);
for (const k of keys) {
if (normalizeDamagedPartKeyLoose(k) === target) {
b = getCaptureBlob(damagedPartsData, k);
if (b) return b;
}
}
return undefined;
}
/**
* True if a capture exists for catalog/selected part key {@code partKey}, including blobs stored
* under humanized keys (e.g. {@code Left Backfender} vs {@code left_backfender}).
*/
export function hasDamagedPartCapture(
damagedPartsData: unknown,
partKey: string,
selectedNormalized?: DamageSelectedPartV2[],
): boolean {
return (
getDamagedPartCaptureBlob(damagedPartsData, partKey, selectedNormalized) !=
null
);
}
/**
* Map a client {@code captureKey} to the canonical {@code damage.selectedParts} id when unambiguous.
*/
export function resolveCanonicalDamagedPartStorageKey(
captureKeyRaw: string,
selectedParts: unknown,
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): string {
const raw = String(captureKeyRaw ?? "").trim();
if (!raw) return raw;
const parts = damageSelectedPartsToLookupStrings(
selectedParts,
carType,
selectedOuterPartsLegacy,
);
if (parts.includes(raw)) return raw;
const n = normalizeDamagedPartKeyLoose(raw);
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);
if (matches.length === 1) return matches[0];
return raw;
}

View File

@@ -0,0 +1,341 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import {
classifyV2ExpertPricingParts,
claimCaseStatusAllowsOwnerInsurerSignStep,
claimCaseStatusIsV2OwnerPostExpertPipeline,
getActiveV2ExpertReply,
objectionDisallowedDueToOutstandingFactorWorkflow,
} from "src/helpers/claim-v2-expert-reply-workflow";
const BASE = "v2/claim-request-management";
/** One actionable next step for owner UI (`pathTemplate` embeds claim id; replace `{partId}` per line item). */
export interface ClaimDetailsOwnerNextActionV2 {
key: string;
method?: string;
pathTemplate?: string;
description: string;
}
/** Derived hints so the client can route without re-implementing server rules (returned only for claim owners, not FIELD_EXPERT). */
export interface ClaimDetailsOwnerGuidanceV2 {
phaseKey: string;
headline: string;
detail?: string;
nextActions: ClaimDetailsOwnerNextActionV2[];
objectionAllowed?: boolean;
objectionHint?: string;
}
function objectionWindowForOwner(claim: any): {
pricingEligible: boolean;
resendEligible: boolean;
} {
const r = claim.evaluation?.damageExpertResend;
const hasExpertResend = !!(
r &&
(String(r.resendDescription || "").trim() !== "" ||
(Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) ||
(Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0))
);
const pendingResend =
hasExpertResend &&
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
claim.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
let pricingEligible = false;
if (
claimCaseStatusAllowsOwnerInsurerSignStep(claim.status) &&
claim.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
!claim.evaluation?.ownerInsurerApproval?.signedAt
) {
if (!objectionDisallowedDueToOutstandingFactorWorkflow(claim)) {
const active = getActiveV2ExpertReply(
claim as { evaluation?: Record<string, unknown> },
);
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
const atMixed =
claim.claimStatus === ClaimStatus.NEEDS_REVISION &&
shape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt;
const atFinal = claim.claimStatus === ClaimStatus.APPROVED;
pricingEligible = atMixed || atFinal;
}
}
return { pricingEligible, resendEligible: !!pendingResend };
}
/** Mirrors server rules for PUT .../objection (read-only hint for UI). */
export function ownerMaySubmitObjectionForClaimDetails(claim: any): boolean {
if (claim.evaluation?.objection?.submittedAt) return false;
if (claim.evaluation?.damageExpertReplyFinal) return false;
const { pricingEligible, resendEligible } = objectionWindowForOwner(claim);
if (resendEligible) return true;
return (
pricingEligible &&
!claim.evaluation?.ownerInsurerApproval?.signedAt &&
!objectionDisallowedDueToOutstandingFactorWorkflow(claim)
);
}
function objectionHintWhen(allowed: boolean): string | undefined {
return allowed
? "Optional: PUT v2/claim-request-management/request/{claimRequestId}/objection — only priced repair lines (`factorNeeded=false` in the expert reply)."
: undefined;
}
/**
* Build owner-facing guidance for GET claim details V2.
* Caller should omit this field for FIELD_EXPERT viewers.
*/
export function buildClaimDetailsV2OwnerGuidance(
claim: any,
claimRequestId: string,
): ClaimDetailsOwnerGuidanceV2 {
const id = claimRequestId;
const act = (key: string, method: string, path: string, description: string) =>
({ key, method, pathTemplate: path, description });
const objectionAllowed = ownerMaySubmitObjectionForClaimDetails(claim);
const objectionHint = objectionHintWhen(objectionAllowed);
if (claim.status === ClaimCaseStatus.COMPLETED) {
return {
phaseKey: "COMPLETED",
headline: "Claim completed",
detail:
claim.userRating?.createdAt
? "Rating already submitted."
: "You may submit a satisfaction rating once.",
nextActions: [
act(
"USER_RATING",
"PUT",
`${BASE}/request/${id}/user-rating`,
"Optional satisfaction rating",
),
],
objectionAllowed: false,
};
}
if (claim.status === ClaimCaseStatus.REJECTED || claim.claimStatus === ClaimStatus.REJECTED) {
return {
phaseKey: "REJECTED",
headline: "Claim rejected",
detail: "This case is closed as rejected.",
nextActions: [],
objectionAllowed: false,
};
}
if (claim.status === ClaimCaseStatus.CANCELLED) {
return {
phaseKey: "CANCELLED",
headline: "Claim cancelled",
nextActions: [],
objectionAllowed: false,
};
}
if (claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
return {
phaseKey: "EXPERT_RESEND",
headline: "Expert requested more information",
detail:
"Upload requested documents or damaged-part photos, or acknowledge if the expert gave instructions only.",
nextActions: [
act(
"UPLOAD_DOC",
"POST",
`${BASE}/upload-document/${id}`,
"Multipart: required document upload",
),
act(
"CAPTURE_PART",
"POST",
`${BASE}/capture-part/${id}`,
"Multipart: damaged-part or angle capture",
),
act(
"RESEND_ACK",
"POST",
`${BASE}/request/${id}/expert-resend/acknowledge`,
"If no document/part list — instructions only",
),
],
objectionAllowed: ownerMaySubmitObjectionForClaimDetails(claim),
objectionHint: objectionHintWhen(ownerMaySubmitObjectionForClaimDetails(claim)),
};
}
if (claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
return {
phaseKey: "WAITING_DAMAGE_EXPERT",
headline: "Waiting for damage expert",
detail: "Your file is in the expert queue.",
nextActions: [],
objectionAllowed: false,
};
}
if (claim.status === ClaimCaseStatus.EXPERT_REVIEWING) {
return {
phaseKey: "EXPERT_REVIEWING",
headline: "Expert is reviewing",
detail: "A damage expert is assessing your claim.",
nextActions: [],
objectionAllowed: false,
};
}
if (!claimCaseStatusIsV2OwnerPostExpertPipeline(claim.status)) {
return {
phaseKey: "USER_FLOW",
headline: "Continue your claim registration",
detail: "Complete remaining selection, captures, documents, or bank info.",
nextActions: [
act(
"CAPTURE_REQUIREMENTS",
"GET",
`${BASE}/capture-requirements/${id}`,
"Shows remaining angles, parts, documents",
),
act(
"UPLOAD_DOC",
"POST",
`${BASE}/upload-document/${id}`,
"Upload required document when workflow allows",
),
act(
"CAPTURE_PART",
"POST",
`${BASE}/capture-part/${id}`,
"Upload angle or damaged-part photo when workflow allows",
),
],
objectionAllowed: false,
};
}
const step = claim.workflow?.currentStep;
const cs = claim.claimStatus;
if (step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS && cs === ClaimStatus.NEEDS_REVISION) {
return {
phaseKey: "UPLOAD_FACTORS",
headline: "Upload repair factor files",
detail:
"One upload per factor-needed part. When all factors are uploaded, the expert validates costs.",
nextActions: [
act(
"UPLOAD_FACTOR",
"PATCH",
`${BASE}/request/reply/${id}/{partId}/upload-factor`,
"Multipart `file` — use each expert reply `partId` with factorNeeded=true",
),
],
objectionAllowed: false,
};
}
if (step === ClaimWorkflowStep.EXPERT_COST_EVALUATION && cs === ClaimStatus.UNDER_REVIEW) {
return {
phaseKey: "EXPERT_VALIDATING_FACTORS",
headline: "Expert is validating factors",
detail: "No owner action until the expert finishes validating repair-factor pricing.",
nextActions: [],
objectionAllowed: false,
};
}
if (step === ClaimWorkflowStep.INSURER_REVIEW) {
if (cs === ClaimStatus.NEEDS_REVISION) {
const active = getActiveV2ExpertReply(
claim as { evaluation?: Record<string, unknown> },
);
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
if (
shape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
) {
const ow = objectionWindowForOwner(claim);
const allowObj = ow.pricingEligible;
return {
phaseKey: "SIGN_PRICED_LINES",
headline: "Sign acceptance of priced lines",
detail:
"Required before uploading repair-factor files for mixed priced + factor-needed replies.",
nextActions: [
act(
"SIGN_PRICED_PARTS",
"PUT",
`${BASE}/request/${id}/owner-insurer-approval/sign`,
"Multipart sign + agree + branchId",
),
],
objectionAllowed: allowObj,
objectionHint: objectionHintWhen(allowObj),
};
}
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return {
phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
headline: "Insurer approval — action pending",
detail:
"If you see priced lines without factors, use sign or objection; otherwise verify workflow data.",
nextActions: [
act(
"SIGN_OR_CHECK",
"PUT",
`${BASE}/request/${id}/owner-insurer-approval/sign`,
"Sign if prompted by app state",
),
],
objectionAllowed: allowObj,
objectionHint: objectionHintWhen(allowObj),
};
}
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return {
phaseKey: "FINAL_SIGN_OR_REJECT",
headline: "Accept or reject final pricing",
detail: "Sign to complete the claim, or reject. You may object to priced lines instead of signing.",
nextActions: [
act(
"FINAL_SIGN",
"PUT",
`${BASE}/request/${id}/owner-insurer-approval/sign`,
"Multipart sign + agree + branchId — completes or rejects claim",
),
act(
"OBJECTION",
"PUT",
`${BASE}/request/${id}/objection`,
"Dispute priced lines before signing",
),
],
objectionAllowed: allowObj,
objectionHint: objectionHintWhen(allowObj),
};
}
}
return {
phaseKey: "INSURER_APPROVAL_FALLBACK",
headline: "Awaiting next step",
detail:
"Inspect `status`, `claimStatus`, `workflow.currentStep`/`nextStep` or contact support.",
nextActions: [],
objectionAllowed,
objectionHint,
};
}

View File

@@ -0,0 +1,118 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
import { normalizeDamageSelectedParts } from "./outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export function resendRequestHasPayload(r: {
resendDescription?: string;
resendDocuments?: unknown[];
resendCarParts?: unknown[];
}): boolean {
if (!r) return false;
if (String(r.resendDescription || "").trim() !== "") return true;
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
return false;
}
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
if (!Array.isArray(docs)) return [];
const keys: string[] = [];
for (const d of docs) {
if (typeof d === "string" && d.trim()) {
const c = canonicalizeResendDocumentKey(d.trim());
if (c) keys.push(c);
} else if (d && typeof d === "object" && "key" in (d as object)) {
const k = String((d as { key?: string }).key || "").trim();
const c = canonicalizeResendDocumentKey(k);
if (c) keys.push(c);
}
}
return [...new Set(keys)];
}
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
if (!Array.isArray(parts)) return [];
const keys: string[] = [];
for (const p of parts) {
if (typeof p === "string" && p.trim()) keys.push(p.trim());
else if (p && typeof p === "object" && "key" in (p as object)) {
const k = String((p as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
}
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
const rd = claim?.requiredDocuments;
if (!rd) return undefined;
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
}
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean {
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined;
return !!doc?.uploaded;
}
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts;
if (!data) return false;
const norm = normalizeDamageSelectedParts(
claim?.damage?.selectedParts,
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
claim?.damage?.selectedOuterParts,
);
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
}
/**
* True when the user has satisfied every document and part the damage expert asked for,
* or when the expert only left instructions (no doc/part list) and there is a description.
*/
export function canFinalizeExpertResend(claim: any): boolean {
if (!claim) return false;
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false;
const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
const partKeys = normalizeResendPartKeys(r.resendCarParts);
if (docKeys.length === 0 && partKeys.length === 0) {
return String(r.resendDescription || "").trim() !== "";
}
for (const k of docKeys) {
if (!isRequiredDocumentUploaded(claim, k)) return false;
}
for (const k of partKeys) {
if (!hasDamagedPartCapture(claim, k)) return false;
}
return true;
}
export function documentKeyAllowedForExpertResend(
claim: any,
documentKey: string,
): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
const normalized =
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
return allowed.has(normalized);
}
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
return allowed.has(partKey);
}

View File

@@ -0,0 +1,35 @@
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
const REQUIRED_VALUES = new Set<string>(
Object.values(ClaimRequiredDocumentType),
);
/**
* Non-canonical document keys that may still appear on stored
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
*/
const RESEND_DOCUMENT_KEY_ALIASES: Readonly<
Record<string, ClaimRequiredDocumentType>
> = {
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
};
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */
export function canonicalizeResendDocumentKey(
raw: string,
): ClaimRequiredDocumentType | null {
const t = String(raw || "").trim();
if (!t) return null;
if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType;
return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null;
}

View File

@@ -0,0 +1,129 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
export interface ClaimPricingPartLite {
factorNeeded?: boolean;
partId?: string;
}
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {
replyKey: "damageExpertReplyFinal" | "damageExpertReply";
reply: Record<string, unknown>;
parts: ClaimPricingPartLite[];
} | null {
const fin = claim.evaluation?.damageExpertReplyFinal as
| { parts?: ClaimPricingPartLite[]; submittedAt?: Date }
| undefined;
const ini = claim.evaluation?.damageExpertReply as
| { parts?: ClaimPricingPartLite[]; submittedAt?: Date }
| undefined;
if (fin?.submittedAt && fin?.parts?.length) {
return { replyKey: "damageExpertReplyFinal", reply: fin as Record<string, unknown>, parts: fin.parts };
}
if (ini?.submittedAt && ini?.parts?.length) {
return { replyKey: "damageExpertReply", reply: ini as Record<string, unknown>, parts: ini.parts };
}
return null;
}
export function classifyV2ExpertPricingParts(parts: ClaimPricingPartLite[]) {
const factorNeeded = parts.filter((p) => p.factorNeeded === true);
const priced = parts.filter((p) => p.factorNeeded !== true);
return {
pricedPartsCount: priced.length,
factorNeededPartsCount: factorNeeded.length,
mixedFactorAndPrice:
factorNeeded.length > 0 && priced.length > 0,
allFactorLines: factorNeeded.length > 0 && priced.length === 0,
noFactors: factorNeeded.length === 0,
};
}
const OWNER_POST_EXPERT_INSURER_SIGNATURE_STATUSES: ReadonlySet<string> = new Set([
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
]);
const OWNER_FACTOR_UPLOAD_PHASE_STATUSES: ReadonlySet<string> = new Set([
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
]);
const V2_POST_EXPERT_OWNER_PIPELINE_STATUSES: ReadonlySet<string> = new Set([
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
]);
/** Owner multipart sign at `INSURER_REVIEW` (priced-line gate or final acceptance). */
export function claimCaseStatusAllowsOwnerInsurerSignStep(
status: ClaimCaseStatus | string | undefined,
): boolean {
return OWNER_POST_EXPERT_INSURER_SIGNATURE_STATUSES.has(String(status ?? ""));
}
/** Repair-factor upload during `NEEDS_REVISION` @ `OWNER_UPLOAD_FACTOR_DOCUMENTS`. */
export function claimCaseStatusAllowsOwnerFactorUploadDuringRevision(
status: ClaimCaseStatus | string | undefined,
): boolean {
return OWNER_FACTOR_UPLOAD_PHASE_STATUSES.has(String(status ?? ""));
}
/** Owner-facing post-expert flow: signatures, factor uploads, or waiting on expert factor validation. */
export function claimCaseStatusIsV2OwnerPostExpertPipeline(
status: ClaimCaseStatus | string | undefined,
): boolean {
return V2_POST_EXPERT_OWNER_PIPELINE_STATUSES.has(String(status ?? ""));
}
export function claimIsAwaitingExpertFactorValidationV2(claim: {
status?: ClaimCaseStatus | string;
claimStatus?: ClaimStatus;
workflow?: { currentStep?: string };
}): boolean {
return (
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION &&
(claim.status === ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS ||
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL)
);
}
export function claimCaseStatusAfterExpertReplyV2(parts: ClaimPricingPartLite[]): ClaimCaseStatus {
const { mixedFactorAndPrice } = classifyV2ExpertPricingParts(parts);
const needsFactorUpload = parts.some((p) => p.factorNeeded === true);
if (!needsFactorUpload) {
return ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN;
}
if (mixedFactorAndPrice) {
return ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING;
}
return ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING;
}
export function claimIsV2ExpertFactorUploadStep(claim: { workflow?: { currentStep?: string } }) {
return claim.workflow?.currentStep === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
}
export function claimIsV2PendingFactorExpertValidation(claim: {
status?: ClaimCaseStatus;
claimStatus?: ClaimStatus;
workflow?: { currentStep?: string };
}) {
return claimIsAwaitingExpertFactorValidationV2(claim);
}
export function objectionDisallowedDueToOutstandingFactorWorkflow(claim: {
workflow?: { currentStep?: string };
}) {
const step = claim.workflow?.currentStep;
return (
step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS ||
step === ClaimWorkflowStep.EXPERT_COST_EVALUATION
);
}

View File

@@ -1,3 +1,5 @@
import * as jMoment from "jalali-moment";
/** /**
* Converts an ISO date string or Date to Jalali (Persian) date and time. * Converts an ISO date string or Date to Jalali (Persian) date and time.
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date * @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
@@ -12,6 +14,66 @@ export function toJalaliDateAndTime(
return [dateStr, timeStr]; return [dateStr, timeStr];
} }
/**
* Converts a Jalali (Persian) date in any of the common shapes used across
* this codebase to a Gregorian `YYYY-MM-DD` string.
*
* Accepted input shapes:
* - number `13770624` (packed `YYYYMMDD`)
* - string `"13770624"` / `"1377-06-24"` / `"1377/06/24"` / `"1377/6/24"`
* - already-Gregorian `YYYY-MM-DD` strings are returned untouched as long as
* they parse (we treat them as Gregorian only when the year is clearly
* Gregorian, i.e. >= 1900). Otherwise the year is treated as Jalali.
*
* Returns `null` when the input cannot be parsed into a valid Jalali date.
*/
export function jalaliToGregorianDate(
input: string | number | null | undefined,
): string | null {
if (input === null || input === undefined) return null;
const raw =
typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null;
let year = 0;
let month = 0;
let day = 0;
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
if (separated) {
year = parseInt(separated[1], 10);
month = parseInt(separated[2], 10);
day = parseInt(separated[3], 10);
} else {
const digits = raw.replace(/\D/g, "");
if (digits.length === 8) {
year = parseInt(digits.slice(0, 4), 10);
month = parseInt(digits.slice(4, 6), 10);
day = parseInt(digits.slice(6, 8), 10);
} else {
return null;
}
}
if (!year || !month || !day) return null;
// If the year already looks Gregorian (>= 1900), assume the caller already
// sent a Gregorian date and just normalise the formatting.
if (year >= 1900) {
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
const m = jMoment(`${year}-${mm}-${dd}`, "YYYY-MM-DD");
return m.isValid() ? m.format("YYYY-MM-DD") : null;
}
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
const jm = jMoment(`${year}-${mm}-${dd}`, "jYYYY-jMM-jDD");
if (!jm.isValid()) return null;
return jm.format("YYYY-MM-DD");
}
/** /**
* Converts a Date to Jalali date string (e.g. 1404/11/24). * Converts a Date to Jalali date string (e.g. 1404/11/24).
*/ */

View File

@@ -0,0 +1,49 @@
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
CaseStatus.OPEN,
CaseStatus.WAITING_FOR_SECOND_PARTY,
]);
/**
* Claim: user flow before submission is complete (analog to `ClaimWorkflowStep.USER_SUBMISSION_COMPLETE`).
*/
const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
ClaimCaseStatus.CREATED,
ClaimCaseStatus.SELECTING_OUTER_PARTS,
ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
]);
export function blameCaseStatusToReportBucket(status: string): string {
if (BLAME_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
return status;
}
export function claimCaseStatusToReportBucket(status: string): string {
if (CLAIM_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
return status;
}
/** Keys returned for blame V2 expert report (IN_PROGRESS + each native post-early status). */
export function initialBlameExpertReportBuckets(): Record<string, number> {
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
for (const s of Object.values(CaseStatus)) {
if (BLAME_IN_PROGRESS_STATUSES.has(s)) continue;
out[s] = 0;
}
return out;
}
/** Keys returned for claim V2 expert report. */
export function initialClaimExpertReportBuckets(): Record<string, number> {
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
for (const s of Object.values(ClaimCaseStatus)) {
if (CLAIM_IN_PROGRESS_STATUSES.has(s)) continue;
out[s] = 0;
}
return out;
}

View File

@@ -0,0 +1,29 @@
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { ExpertProfileSnapshot } from "src/request-management/entities/schema/expert-profile-snapshot.schema";
export function snapshotFromFieldExpert(
doc: ExpertModel | null | undefined,
): ExpertProfileSnapshot | undefined {
if (!doc) return undefined;
return {
firstName: doc.firstName ?? undefined,
lastName: doc.lastName ?? undefined,
email: doc.email ?? undefined,
insuActivityCo: doc.insuActivityCo ?? undefined,
mobile: (doc.mobile ?? doc.phone) ?? undefined,
};
}
export function snapshotFromDamageExpert(
doc: DamageExpertModel | null | undefined,
): ExpertProfileSnapshot | undefined {
if (!doc) return undefined;
return {
firstName: doc.firstName ?? undefined,
lastName: doc.lastName ?? undefined,
email: doc.email ?? undefined,
insuActivityCo: doc.insuActivityCo ?? undefined,
mobile: doc.mobile ?? undefined,
};
}

View File

@@ -0,0 +1,115 @@
import { Connection, Query, Schema } from "mongoose";
const PERSIAN_TO_ENGLISH: Record<string, string> = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
};
function normalizeDigits(value: string): string {
return value.replace(/[۰-۹]/g, (ch) => PERSIAN_TO_ENGLISH[ch] ?? ch);
}
function toIranFaDateTimeTuple(input: Date): [string, string] {
const dateFa = normalizeDigits(
input.toLocaleDateString("fa-IR", {
timeZone: "Asia/Tehran",
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
);
const timeFa = normalizeDigits(
input.toLocaleTimeString("fa-IR", {
timeZone: "Asia/Tehran",
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
);
return [dateFa, timeFa];
}
function isMissingFaTuple(value: unknown): boolean {
if (value == null) return true;
if (!Array.isArray(value)) return false;
if (value.length < 2) return true;
const [d, t] = value;
return !d || !t;
}
function writeFaTimestampsOnUpdate(query: Query<any, any>): void {
const nowFa = toIranFaDateTimeTuple(new Date());
const update = (query.getUpdate() ?? {}) as Record<string, any>;
if (!update.$set || typeof update.$set !== "object") {
update.$set = {};
}
update.$set.updatedAtFa = nowFa;
// Keep createdAtFa immutable on regular updates; set only for upsert inserts.
const opts = (query as any).getOptions?.() ?? {};
if (opts.upsert) {
if (!update.$setOnInsert || typeof update.$setOnInsert !== "object") {
update.$setOnInsert = {};
}
if (isMissingFaTuple(update.$setOnInsert.createdAtFa)) {
update.$setOnInsert.createdAtFa = nowFa;
}
}
query.setUpdate(update);
}
/**
* Adds `createdAtFa` / `updatedAtFa` (Iran timezone tuple: [date, time])
* to all schemas that use mongoose timestamps.
*/
export function applyIranFaTimestampPlugin(connection: Connection): void {
connection.plugin((schema: Schema) => {
const timestamps = schema.get("timestamps");
if (!timestamps) return;
schema.add({
createdAtFa: { type: [String], index: true },
updatedAtFa: { type: [String], index: true },
});
schema.pre("save", function (next) {
const nowFa = toIranFaDateTimeTuple(new Date());
if (isMissingFaTuple((this as any).createdAtFa)) {
(this as any).createdAtFa = nowFa;
}
(this as any).updatedAtFa = nowFa;
next();
});
schema.pre("insertMany", function (next, docs: any[]) {
const nowFa = toIranFaDateTimeTuple(new Date());
for (const doc of docs) {
if (isMissingFaTuple(doc.createdAtFa)) doc.createdAtFa = nowFa;
doc.updatedAtFa = nowFa;
}
next();
});
schema.pre("findOneAndUpdate", function () {
writeFaTimestampsOnUpdate(this);
});
schema.pre("updateOne", function () {
writeFaTimestampsOnUpdate(this);
});
schema.pre("updateMany", function () {
writeFaTimestampsOnUpdate(this);
});
});
}

View File

@@ -0,0 +1,700 @@
import {
ClaimVehicleTypeV2,
OUTER_PARTS_BY_CAR_TYPE,
OuterPartCatalogItem,
OuterPartSideV2,
} from "src/static/outer-car-parts-catalog";
/** Stored on `damage.selectedParts` and used when resolving captures / APIs. */
export type DamageSelectedPartV2 = {
id: number | null;
name: string;
side: string;
label_fa: string;
/** Original catalog key when known (e.g. `left_backfender`). */
catalogKey?: string;
};
const SIDE_SET = new Set<string>(Object.values(OuterPartSideV2));
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
const SIDE_LABEL_FA: Record<string, string> = {
[OuterPartSideV2.LEFT]: "چپ",
[OuterPartSideV2.RIGHT]: "راست",
[OuterPartSideV2.FRONT]: "جلو",
[OuterPartSideV2.BACK]: "عقب",
[OuterPartSideV2.TOP]: "بالا",
};
function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] {
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
if (list.some((x) => x.key === item.key || x.id === item.id)) {
return list;
}
}
return [item];
}
/**
* When multiple catalog rows share the same `titleFa` (e.g. left/right rear fender),
* append ` (side)` in Farsi so clients can display `label_fa` alone.
*/
export function disambiguateOuterPartLabelFa(
baseTitleFa: string,
side: string,
sameTypeCatalog: OuterPartCatalogItem[],
): string {
const t = String(baseTitleFa ?? "").trim();
const s = String(side ?? "").trim();
if (!t || !sameTypeCatalog?.length) return t;
const norm = (x: string) => x.trim();
const siblings = sameTypeCatalog.filter((p) => norm(p.titleFa) === norm(t));
if (siblings.length <= 1) return t;
const sideFa = SIDE_LABEL_FA[s] || s;
if (!sideFa) return t;
return `${t} (${sideFa})`;
}
const CATALOG_ITEM_BY_ID = new Map<number, OuterPartCatalogItem>();
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
for (const it of list) {
CATALOG_ITEM_BY_ID.set(it.id, it);
}
}
function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined {
for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) {
const hit = list.find((x) => x.key === key);
if (hit) return hit;
}
return undefined;
}
/** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */
export function splitCatalogKeyToNameAndSide(
fullKey: string,
): { name: string; side: string } {
const t = String(fullKey ?? "").trim();
const i = t.indexOf("_");
if (i <= 0) return { name: t, side: "" };
const head = t.slice(0, i);
if (SIDE_SET.has(head)) {
return { side: head, name: t.slice(i + 1) };
}
return { name: t, side: "" };
}
export function catalogLikeKeyFromPart(
part: Pick<DamageSelectedPartV2, "side" | "name">,
): string {
if (part.side) return `${part.side}_${part.name}`;
return part.name;
}
export function catalogItemToSelectedPart(
item: OuterPartCatalogItem,
sameTypeCatalog?: OuterPartCatalogItem[],
): DamageSelectedPartV2 {
const { name, side } = splitCatalogKeyToNameAndSide(item.key);
const sideEff = String(side || item.side || "");
const list = sameTypeCatalog ?? outerCatalogListForItem(item);
const label_fa = disambiguateOuterPartLabelFa(item.titleFa, sideEff, list);
return {
id: item.id,
name,
side: sideEff,
label_fa,
catalogKey: item.key,
};
}
const LEGACY_SLUG_LABEL_FA: Record<string, string> = {
hood: "کاپوت",
trunk: "صندوق عقب",
roof: "سقف",
front_right_door: "درب جلو راست",
front_left_door: "درب جلو چپ",
rear_right_door: "درب عقب راست",
rear_left_door: "درب عقب چپ",
front_bumper: "سپر جلو",
rear_bumper: "سپر عقب",
front_right_fender: "گلگیر جلو راست",
front_left_fender: "گلگیر جلو چپ",
rear_right_fender: "گلگیر عقب راست",
rear_left_fender: "گلگیر عقب چپ",
};
function labelFaForUnknownSlug(slug: string): string {
return LEGACY_SLUG_LABEL_FA[slug] || slug;
}
function toNum(v: unknown): number | null {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* Normalize `damage.selectedParts` from DB (strings, legacy objects, or new objects)
* into a stable list for APIs and capture resolution.
*/
export function normalizeDamageSelectedParts(
raw: unknown,
carType?: ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): DamageSelectedPartV2[] {
const arr = Array.isArray(raw) ? raw : [];
const legacyOuter = Array.isArray(selectedOuterPartsLegacy)
? selectedOuterPartsLegacy
: [];
const byKeyFromLegacy = new Map<string, { id?: number; side?: string }>();
for (const o of legacyOuter) {
if (o && typeof o === "object" && "key" in (o as object)) {
const rec = o as { key?: string; id?: number; side?: string };
const k = String(rec.key || "").trim();
if (k) byKeyFromLegacy.set(k, { id: rec.id, side: rec.side });
}
}
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
const byCatalogKey = catalog
? new Map(catalog.map((c) => [c.key, c]))
: undefined;
const out: DamageSelectedPartV2[] = [];
for (const el of arr) {
if (el == null) continue;
if (typeof el === "string") {
const s = el.trim();
if (!s) continue;
let catItem = byCatalogKey?.get(s) ?? findCatalogItemByKey(s);
if (!catItem && /^\d+$/.test(s)) {
catItem = CATALOG_ITEM_BY_ID.get(Number(s));
}
if (catItem) {
const list = catalog ?? outerCatalogListForItem(catItem);
out.push(catalogItemToSelectedPart(catItem, list));
continue;
}
const hint = byKeyFromLegacy.get(s);
if (hint?.side || hint?.id != null) {
const split = splitCatalogKeyToNameAndSide(s);
if (split.side) {
const sideEff = hint?.side ?? split.side;
const baseFa = labelFaForUnknownSlug(s);
const list = catalog ?? [];
const label_fa =
catalog?.length && baseFa
? disambiguateOuterPartLabelFa(baseFa, String(sideEff), catalog)
: baseFa;
out.push({
id: hint?.id ?? null,
name: split.name,
side: sideEff,
label_fa,
catalogKey: s,
});
continue;
}
}
out.push({
id: hint?.id ?? null,
name: s,
side: hint?.side ?? "",
label_fa: labelFaForUnknownSlug(s),
catalogKey: s,
});
continue;
}
if (typeof el === "object") {
const o = el as Record<string, unknown>;
if (typeof o.name === "string" && o.name.trim()) {
let name = o.name.trim();
const inner = splitCatalogKeyToNameAndSide(name);
if (inner.side && inner.name) {
name = inner.name;
}
const side = typeof o.side === "string" ? o.side : inner.side || "";
const id = toNum(o.id);
const ck =
typeof o.catalogKey === "string" && o.catalogKey.trim()
? o.catalogKey.trim()
: undefined;
let catItem: OuterPartCatalogItem | undefined;
if (catalog && byCatalogKey) {
if (ck) catItem = byCatalogKey.get(ck);
if (!catItem && id != null) catItem = catalog.find((c) => c.id === id);
if (!catItem && side && name) {
catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name }));
}
} else {
if (ck) catItem = findCatalogItemByKey(ck);
if (!catItem && id != null) {
catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined;
}
if (!catItem && side && name) {
catItem = findCatalogItemByKey(
catalogLikeKeyFromPart({ side, name }),
);
}
}
const fallbackLabel =
typeof o.label_fa === "string" && o.label_fa.trim()
? o.label_fa.trim()
: typeof o.labelFa === "string" && o.labelFa.trim()
? (o.labelFa as string).trim()
: name;
const listForDis = catalog?.length
? catalog
: catItem
? outerCatalogListForItem(catItem)
: [];
const label_fa =
catItem && listForDis.length
? disambiguateOuterPartLabelFa(
catItem.titleFa,
catItem.side,
listForDis,
)
: fallbackLabel;
out.push({
id,
name,
side,
label_fa,
catalogKey: ck,
});
continue;
}
if (typeof o.key === "string" && o.key.trim()) {
const k = o.key.trim();
const cat =
byCatalogKey?.get(k) ??
findCatalogItemByKey(k) ??
(toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined);
if (cat) {
const list = catalog ?? outerCatalogListForItem(cat);
out.push(catalogItemToSelectedPart(cat, list));
continue;
}
const split = splitCatalogKeyToNameAndSide(k);
out.push({
id: toNum(o.id),
name: split.side ? split.name : k,
side: typeof o.side === "string" ? o.side : split.side,
label_fa:
typeof o.label_fa === "string" && o.label_fa.trim()
? (o.label_fa as string).trim()
: labelFaForUnknownSlug(k),
catalogKey: k,
});
}
}
}
return out;
}
export function partIdentityKey(p: DamageSelectedPartV2): string {
if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`;
return `${p.side}|${p.name}`;
}
function normPartSegment(s: string): string {
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
}
function looseCatalogNameMatch(
catalogItem: OuterPartCatalogItem,
expertPart: string,
): boolean {
const { name } = splitCatalogKeyToNameAndSide(catalogItem.key);
return normPartSegment(name) === normPartSegment(expertPart);
}
/**
* Map legacy expert reply `{ part: "backFender", side: "left" }` to a catalog row
* when `carType` is known (or scan all types if omitted).
*/
export function findCatalogItemFromLegacyExpertPart(
part: string,
side: string,
carType?: ClaimVehicleTypeV2,
): OuterPartCatalogItem | undefined {
const s = String(side ?? "").toLowerCase().trim();
const lists = carType
? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][]
: (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]);
for (const catalog of lists) {
if (!catalog?.length) continue;
for (const it of catalog) {
if (String(it.side).toLowerCase() !== s) continue;
if (looseCatalogNameMatch(it, part)) return it;
}
}
return undefined;
}
function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record<string, unknown> {
const r: Record<string, unknown> = {
name: p.name,
side: String(p.side || "").toLowerCase(),
label_fa: p.label_fa,
};
if (p.id != null && Number.isFinite(p.id)) r.id = p.id;
if (p.catalogKey) r.catalogKey = p.catalogKey;
return r;
}
/**
* Canonical shape for `evaluation.damageExpertReply*.parts[].carPartDamage`
* (same fields as owner `damage.selectedParts` items).
*/
export function normalizeCarPartDamageForExpertReply(
raw: unknown,
carType?: ClaimVehicleTypeV2,
): Record<string, unknown> {
if (raw == null) {
throw new Error("carPartDamage is required");
}
if (typeof raw === "string") {
const s = raw.trim();
if (!s) throw new Error("carPartDamage is empty");
const fromKey = findCatalogItemByKey(s);
if (fromKey) {
const list = outerCatalogListForItem(fromKey);
return selectedPartToStoredRecord(
catalogItemToSelectedPart(fromKey, list),
);
}
return { name: s, side: "", label_fa: s };
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("Invalid carPartDamage");
}
const o = raw as Record<string, unknown>;
const legacyPart =
typeof o.part === "string" && o.part.trim() ? o.part.trim() : "";
const legacySide =
typeof o.side === "string" && o.side.trim()
? o.side.trim().toLowerCase()
: "";
const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : "";
const sideIn =
typeof o.side === "string" && o.side.trim()
? o.side.trim().toLowerCase()
: "";
if (name && sideIn) {
const id = toNum(o.id);
const ck =
typeof o.catalogKey === "string" && o.catalogKey.trim()
? o.catalogKey.trim()
: undefined;
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
let catItem: OuterPartCatalogItem | undefined;
if (catalog) {
const byKey = new Map(catalog.map((c) => [c.key, c]));
if (ck) catItem = byKey.get(ck);
if (!catItem && id != null) catItem = catalog.find((c) => c.id === id);
if (!catItem) {
catItem = byKey.get(catalogLikeKeyFromPart({ side: sideIn, name }));
}
}
if (!catItem && id != null) {
catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined;
}
const list = catalog?.length
? catalog
: catItem
? outerCatalogListForItem(catItem)
: [];
if (catItem && list.length) {
return selectedPartToStoredRecord(
catalogItemToSelectedPart(catItem, list),
);
}
const label_fa =
typeof o.label_fa === "string" && o.label_fa.trim()
? o.label_fa.trim()
: name;
const out: Record<string, unknown> = {
name,
side: sideIn,
label_fa,
};
if (id != null) out.id = id;
if (ck) out.catalogKey = ck;
return out;
}
if (legacyPart && legacySide) {
const catItem = findCatalogItemFromLegacyExpertPart(
legacyPart,
legacySide,
carType,
);
if (catItem) {
const list = outerCatalogListForItem(catItem);
return selectedPartToStoredRecord(
catalogItemToSelectedPart(catItem, list),
);
}
const sideFa = SIDE_LABEL_FA[legacySide] || legacySide;
return {
name: legacyPart,
side: legacySide,
label_fa: `${legacyPart} (${sideFa})`,
};
}
throw new Error("carPartDamage must include side and (name or part)");
}
/** Best-effort migration for API output; never throws. */
export function migrateExpertReplyCarPartDamageToUnified(
raw: unknown,
carType?: ClaimVehicleTypeV2,
): Record<string, unknown> {
try {
return normalizeCarPartDamageForExpertReply(raw, carType);
} catch {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return { name: String(raw ?? ""), side: "", label_fa: String(raw ?? "") };
}
}
/** Stable string for factor DB rows / logs from unified or legacy `carPartDamage`. */
export function carPartDamageLabelForFactorRecord(cd: unknown): string {
if (cd == null) return "";
if (typeof cd === "string") return cd.trim();
if (typeof cd === "object") {
const o = cd as Record<string, unknown>;
if (typeof o.label_fa === "string" && o.label_fa.trim()) {
return o.label_fa.trim();
}
if (typeof o.part === "string" && o.part.trim()) {
const side = typeof o.side === "string" ? o.side.trim() : "";
const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : "";
return sideFa ? `${o.part.trim()} (${sideFa})` : o.part.trim();
}
if (typeof o.name === "string" && o.name.trim()) {
const side = typeof o.side === "string" ? o.side.trim() : "";
const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : "";
return sideFa ? `${o.name.trim()} (${sideFa})` : o.name.trim();
}
}
return String(cd);
}
export function mediaRowHasCapture(row: unknown): boolean {
if (!row || typeof row !== "object") return false;
const r = row as { path?: string; url?: string };
return !!(r.path || r.url);
}
/** Whether `media.damagedParts` is the new array shape (possibly empty). */
export function isDamagedPartsMediaArray(data: unknown): boolean {
return Array.isArray(data);
}
function getLegacyMapRecord(
data: unknown,
): Record<string, unknown> | null {
if (data == null) return null;
if (data instanceof Map) {
return Object.fromEntries((data as Map<string, unknown>).entries());
}
if (typeof data === "object" && !Array.isArray(data)) {
return data as Record<string, unknown>;
}
return null;
}
function getCaptureBlobLoose(
legacyMap: Record<string, unknown>,
partKey: string,
): unknown {
const pk = String(partKey ?? "").trim();
if (!pk) return undefined;
let b = legacyMap[pk];
if (b) return b;
const target = pk.toLowerCase().replace(/[\s_-]/g, "");
for (const k of Object.keys(legacyMap)) {
if (k.toLowerCase().replace(/[\s_-]/g, "") === target) {
return legacyMap[k];
}
}
return undefined;
}
/**
* Align legacy `media.damagedParts` map with `selected` order → array rows
* (metadata + any existing capture blobs).
*/
export function migrateLegacyDamagedPartsMapToArray(
legacyMap: Record<string, unknown>,
selected: DamageSelectedPartV2[],
): Record<string, unknown>[] {
return selected.map((sp) => {
const keysTry = [
sp.catalogKey,
catalogLikeKeyFromPart(sp),
sp.name,
].filter(Boolean) as string[];
let blob: unknown;
for (const k of keysTry) {
blob = getCaptureBlobLoose(legacyMap, k);
if (blob) break;
}
const base = {
id: sp.id ?? undefined,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
};
if (blob && typeof blob === "object") {
const c = blob as Record<string, unknown>;
return {
...base,
...(c.path ? { path: c.path } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.url ? { url: c.url } : {}),
...(c.capturedAt ? { capturedAt: c.capturedAt } : {}),
};
}
return { ...base };
});
}
/**
* Always returns an array view: migrates legacy map → array when needed.
*/
export function coerceDamagedPartsMediaToArray(
damagedPartsData: unknown,
selected: DamageSelectedPartV2[],
): Record<string, unknown>[] {
if (Array.isArray(damagedPartsData)) {
const arr = damagedPartsData.map((x) =>
typeof x === "object" && x ? { ...(x as object) } : {},
) as Record<string, unknown>[];
while (arr.length < selected.length) {
arr.push({});
}
return arr;
}
const legacy = getLegacyMapRecord(damagedPartsData);
if (legacy && selected.length) {
return migrateLegacyDamagedPartsMapToArray(legacy, selected);
}
return [];
}
/**
* Resolve `captureKey` to an index into `selected` / `media.damagedParts`.
*
* Supported keys: catalog id (number string), array index when unambiguous,
* full catalog key (`left_backfender`), or `side_name` matching stored part.
*/
export function resolvePartCaptureIndex(
captureKeyRaw: string,
selected: DamageSelectedPartV2[],
damagedPartsArray?: Record<string, unknown>[],
): number {
const raw = String(captureKeyRaw ?? "").trim();
if (!raw) return -1;
if (selected.length) {
for (let i = 0; i < selected.length; i++) {
const sp = selected[i];
if (sp.catalogKey === raw) return i;
if (catalogLikeKeyFromPart(sp) === raw) return i;
}
if (/^\d+$/.test(raw)) {
const n = Number(raw);
const byId = selected.findIndex((p) => p.id === n);
if (byId >= 0) return byId;
const hasIdCollision = selected.some((p) => p.id === n);
if (!hasIdCollision && n >= 0 && n < selected.length) return n;
}
const loose = raw.toLowerCase().replace(/[\s_-]/g, "");
for (let i = 0; i < selected.length; i++) {
const sp = selected[i];
const cand = [
sp.catalogKey,
catalogLikeKeyFromPart(sp),
sp.name,
].filter(Boolean) as string[];
for (const c of cand) {
if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i;
}
}
}
if (damagedPartsArray && damagedPartsArray.length) {
for (let i = 0; i < damagedPartsArray.length; i++) {
const row = damagedPartsArray[i];
if (!row || typeof row !== "object") continue;
const id = toNum((row as { id?: unknown }).id);
if (id != null && String(id) === raw) return i;
const ck = String((row as { catalogKey?: unknown }).catalogKey || "");
if (ck && ck === raw) return i;
const name = String((row as { name?: unknown }).name || "");
const side = String((row as { side?: unknown }).side || "");
if (side && name && `${side}_${name}` === raw) return i;
}
}
return -1;
}
export function mediaRowOrLegacyHasCapture(
damagedPartsData: unknown,
partKey: string,
selected: DamageSelectedPartV2[],
): boolean {
if (damagedPartsData == null) return false;
if (Array.isArray(damagedPartsData)) {
const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]);
if (idx < 0) return false;
return mediaRowHasCapture((damagedPartsData as unknown[])[idx]);
}
const legacy = getLegacyMapRecord(damagedPartsData);
if (!legacy) return false;
return !!getCaptureBlobLoose(legacy, partKey);
}
export function selectedPartsLegacyStringsForAngles(
selected: DamageSelectedPartV2[],
): string[] {
const s = new Set<string>();
for (const p of selected) {
if (p.catalogKey) s.add(p.catalogKey);
s.add(catalogLikeKeyFromPart(p));
if (p.name) s.add(p.name);
}
return [...s];
}
/** String keys that might appear under legacy `media.damagedParts` for this selection. */
export function damageSelectedPartsToLookupStrings(
raw: unknown,
carType?: ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): string[] {
const norm = normalizeDamageSelectedParts(
raw,
carType,
selectedOuterPartsLegacy,
);
if (norm.length) return selectedPartsLegacyStringsForAngles(norm);
if (Array.isArray(raw)) {
return (raw as unknown[])
.filter((x): x is string => typeof x === "string")
.map((x) => x.trim())
.filter(Boolean);
}
return [];
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
export function requireActorClientKey(actor: { clientKey?: string }): string {
const ck = actor?.clientKey;
if (!ck || typeof ck !== "string") {
throw new BadRequestException(
"Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.",
);
}
return ck;
}
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
return (doc?.parties ?? []).some(
(p: any) => String(p?.person?.clientId ?? "") === id,
);
}
export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
const owner = doc?.owner as
| { clientId?: unknown; userClientKey?: unknown }
| undefined;
const tenantId = owner?.clientId ?? owner?.userClientKey;
return String(tenantId ?? "") === id;
}
/**
* Field expertinitiated cases may not yet have party clientId filled; the
* initiating expert is always scoped to their insurer via JWT.
*/
export function blameCaseAccessibleToExpert(
doc: any,
actor: { sub: string; clientKey?: string },
): boolean {
if (
doc.expertInitiated &&
doc.initiatedByFieldExpertId &&
String(doc.initiatedByFieldExpertId) === actor.sub
) {
return true;
}
const ck = actor?.clientKey;
if (!ck) return false;
return blameCaseTouchesClient(doc, ck);
}
export function assertBlameCaseForExpertTenant(
doc: any,
actor: { sub: string; clientKey?: string },
): void {
if (!blameCaseAccessibleToExpert(doc, actor)) {
throw new ForbiddenException(
"This blame case does not belong to your organization.",
);
}
}
export function assertClaimCaseForTenant(
doc: any,
actor: { clientKey?: string },
): void {
const ck = requireActorClientKey(actor);
if (!claimCaseTouchesClient(doc, ck)) {
throw new ForbiddenException(
"This claim does not belong to your organization.",
);
}
}

View File

@@ -1,401 +1,45 @@
import { import { Injectable, Logger, NotFoundException } from "@nestjs/common";
BadGatewayException,
Injectable,
Logger,
} from "@nestjs/common";
import axios from "axios";
import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
@Injectable() @Injectable()
export class LookupsService { export class LookupsService {
private readonly logger = new Logger(LookupsService.name); private readonly logger = new Logger(LookupsService.name);
private readonly GET_APP_TOKEN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
private readonly LOGIN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
private readonly BASE_API_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
private readonly BASE_INFO_PATH = "base-info";
private readonly CODE_LIST_PATH = "code-list";
// Authentication credentials constructor(private readonly lookupDbService: LookupDbService) {}
private readonly APP_NAME = "fanhab";
private readonly SECRET = "5Fa@N#A2B";
private readonly USERNAME = "fanhabUser";
private readonly PASSWORD = "Fan#@2U$3er";
// API headers
private readonly CORP_ID = "3539";
private readonly CONTRACT_ID = "263";
private readonly LOCATION = "100";
constructor(
private readonly lookupDbService: LookupDbService,
) {}
/** /**
* Step 1: Get appToken from GetAppToken API * Returns stored `response` for a lookup document by `name` in the lookups collection.
* The token is returned in the response header 'appToken'
*/ */
private async getAppToken(): Promise<string> { async getLookup(lookupName: string): Promise<any> {
try { const doc = await this.lookupDbService.findOne({ name: lookupName });
const requestHeaders: any = { if (!doc) {
appname: this.APP_NAME, this.logger.warn(`Lookup not found in database: ${lookupName}`);
secret: this.SECRET, throw new NotFoundException(`Lookup "${lookupName}" not found`);
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
delete requestHeaders["Content-Type"];
const response = await axios({
method: "POST",
url: this.GET_APP_TOKEN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
// Axios lowercases headers, so check both cases
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
this.logger.error("Failed to get appToken from response headers");
this.logger.error("Response status:", response.status);
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
this.logger.error("Available header keys:", Object.keys(response.headers));
this.logger.error("Response data:", response.data);
// Extract error message from response if available
const errorMessage = response.data?.Message ||
response.data?.message ||
response.data ||
"Failed to get appToken from response headers";
throw new BadGatewayException(errorMessage);
}
this.logger.log(`Successfully obtained appToken: ${appToken}`);
return appToken;
} catch (error) {
this.logger.error("Failed to get appToken");
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", error.response?.data);
this.logger.error("Error response headers:", error.response?.headers);
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request headers:", error.config?.headers);
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to get appToken from external API";
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException("Failed to get appToken from external API");
}
} }
return doc.response;
} }
/**
* Step 2: Login to get authenticationToken
* The token is returned in the response body field 'authenticationtoken'
*/
private async login(appToken: string): Promise<string> {
try {
this.logger.log(`Attempting login with appToken: ${appToken}`);
this.logger.log(`Login URL: ${this.LOGIN_URL}`);
this.logger.log(`Username: ${this.USERNAME}`);
const requestHeaders: any = {
appToken: appToken,
userName: this.USERNAME,
password: this.PASSWORD,
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
// Axios adds it automatically, but server doesn't expect it for empty body
delete requestHeaders["Content-Type"];
this.logger.log("Request headers (without password):", {
appToken: appToken,
userName: this.USERNAME,
password: "***",
"Content-Length": "0",
"Content-Type": "removed",
});
const response = await axios({
method: "POST",
url: this.LOGIN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
this.logger.log("Login response status:", response.status);
this.logger.log("Login response data:", JSON.stringify(response.data, null, 2));
this.logger.log("Login response headers:", JSON.stringify(response.headers, null, 2));
// Check response headers first (authenticationToken is in headers, not body)
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
// Fallback to body if not in headers
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
this.logger.log(`Extracted authenticationToken: ${authenticationToken ? 'Found' : 'Not found'}`);
if (!authenticationToken) {
this.logger.error("Failed to get authenticationToken from response");
this.logger.error("Response status:", response.status);
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
this.logger.error("Response headers keys:", Object.keys(response.headers || {}));
this.logger.error("Response data:", JSON.stringify(response.data, null, 2));
this.logger.error("Response data keys:", Object.keys(response.data || {}));
// Extract error message from response if available
const errorMessage = response.data?.Message ||
response.data?.message ||
"Failed to get authenticationToken from response";
throw new BadGatewayException(errorMessage);
}
this.logger.log(`Successfully obtained authenticationToken: ${authenticationToken}`);
return authenticationToken;
} catch (error) {
this.logger.error("Failed to login");
this.logger.error("AppToken used:", appToken);
this.logger.error("Login URL:", this.LOGIN_URL);
this.logger.error("Username:", this.USERNAME);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request method:", error.config?.method);
this.logger.error("Request headers:", JSON.stringify({
...error.config?.headers,
password: "***",
}, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to login to external API";
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException("Failed to login to external API");
}
}
}
/**
* Step 3: Call the lookup API with authenticationToken
*/
private async callLookupApi(
lookupName: string,
authenticationToken: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
try {
const response = await axios.get(url, {
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
});
this.logger.log(`Successfully fetched data for lookup: ${lookupName}`);
return response.data;
} catch (error) {
this.logger.error(`Failed to fetch lookup data for ${lookupName}`);
this.logger.error("URL:", url);
this.logger.error("AuthenticationToken used:", authenticationToken);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request headers:", JSON.stringify(error.config?.headers, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
`Failed to fetch lookup data for ${lookupName}`;
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException(
`Failed to fetch lookup data for ${lookupName}`,
);
}
}
}
/**
* Complete authentication flow and fetch lookup data
*/
private async fetchLookupDataFromExternalApi(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<{ authenticationToken: string; response: any; url: string }> {
// Step 1: Get appToken
const appToken = await this.getAppToken();
// Step 2: Login to get authenticationToken
const authenticationToken = await this.login(appToken);
// Step 3: Call the lookup API
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
const response = await this.callLookupApi(
lookupName,
authenticationToken,
pathType,
);
return { authenticationToken, response, url };
}
/**
* Check if lookup data needs to be updated (once a month)
*/
private shouldUpdateLookup(updatedAt: Date | null): boolean {
if (!updatedAt) {
return true; // Never updated, need to fetch
}
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
return updatedAt < oneMonthAgo;
}
/**
* Get lookup data by name
* Returns cached data if available and not expired, otherwise fetches from external API
*/
async getLookup(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
// Check if we have cached data
const existingLookup = await this.lookupDbService.findOne({
name: lookupName,
});
if (existingLookup && !this.shouldUpdateLookup(existingLookup.updatedAt)) {
this.logger.log(
`Returning cached data for lookup: ${lookupName} (last updated: ${existingLookup.updatedAt})`,
);
return existingLookup.response;
}
// Need to fetch from external API
this.logger.log(`Fetching fresh data for lookup: ${lookupName}`);
const { authenticationToken, response, url } =
await this.fetchLookupDataFromExternalApi(lookupName, pathType);
// Save or update in database
await this.lookupDbService.findOneAndUpdate(
{ name: lookupName },
{
url: url,
authenticationToken: authenticationToken,
name: lookupName,
response: response,
updatedAt: new Date(),
},
);
this.logger.log(`Successfully saved/updated lookup data for: ${lookupName}`);
return response;
}
/**
* Get accident-causes lookup data
*/
async getAccidentCauses(): Promise<any> { async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes", "base-info"); return await this.getLookup("accident-causes");
} }
/**
* Get accident-report-type lookup data
*/
async getAccidentReportType(): Promise<any> { async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type", "code-list"); return await this.getLookup("accident-report-type");
} }
/**
* Get vehicle-use-types lookup data
*/
async getVehicleUseTypes(): Promise<any> { async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types", "base-info"); return await this.getLookup("vehicle-use-types");
} }
/**
* Get dmg-pay-method lookup data
*/
async getDmgPayMethod(): Promise<any> { async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method", "code-list"); return await this.getLookup("dmg-pay-method");
} }
/**
* Get driving-licence-types lookup data
*/
async getDrivingLicenceTypes(): Promise<any> { async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types", "base-info"); return await this.getLookup("driving-licence-types");
} }
/**
* Get accident-culprit-type lookup data
*/
async getAccidentCulpritType(): Promise<any> { async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type", "code-list"); return await this.getLookup("accident-culprit-type");
} }
} }

View File

@@ -32,13 +32,27 @@ async function bootstrap() {
.setVersion("1.0.0") .setVersion("1.0.0")
.addServer(process.env.URL) .addServer(process.env.URL)
.addServer(process.env.BASE_URL + "/api") .addServer(process.env.BASE_URL + "/api")
.addServer("http://192.168.20.249:9001")
.addServer("http://192.168.20.170:9001") .addServer("http://192.168.20.170:9001")
.addServer("http://localhost:9001") .addServer("http://localhost:9001")
.addBearerAuth() .addBearerAuth()
.build(); .build();
const docs = SwaggerModule.createDocument(app, config); const docs = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/docs", app, docs); SwaggerModule.setup("/docs", app, docs, {
swaggerOptions: {
persistAuthorization: true,
docExpansion: "none",
tagsSorter: (a: string, b: string) => {
const priority: Record<string, number> = {
user: 0,
actor: 1,
};
const pa = priority[a] ?? 100;
const pb = priority[b] ?? 100;
if (pa !== pb) return pa - pb;
return a.localeCompare(b);
},
},
});
await app.listen(process.env.PORT); await app.listen(process.env.PORT);
} }

View File

@@ -47,10 +47,6 @@ export class ProfileController {
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
@ApiBody({ type: AddPlateProfileDto }) @ApiBody({ type: AddPlateProfileDto })
async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) { async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) {
console.log(
"🚀 ~ file: profile.controller.ts:50 ~ ProfileController ~ addPlate ~ user:",
user,
);
return await this.plateService.addPlate(user.sub, body); return await this.plateService.addPlate(user.sub, body);
} }

View File

@@ -1,51 +1,99 @@
export class DamageExpertAllRequestsCountReportDtoRs { /**
all: number; * V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
UnChecked: number; * `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
CheckedRequest: number; */
CheckAgain: number; export class InsurerRequestsSummaryDtoRs {
WaitingForUserToResend: number; claim: Record<string, number>;
CloseRequest: number; blame: Record<string, number>;
WaitingForUserCompleted: number; unifiedByPublicId: { total: number };
InPersonVisit: number;
constructor(report: any) { constructor(
this.all = report.all; claim: Record<string, number>,
this.UnChecked = report.UnChecked; blame: Record<string, number>,
this.CheckedRequest = report.CheckedRequest; unifiedTotal: number,
this.CheckAgain = report.CheckAgain; ) {
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
this.InPersonVisit = report.InPersonVisit;
}
}
export class ExpertAllRequestsCountReportDtoRs {
all: number;
UnChecked: number;
CheckedRequest: number;
CheckAgain: number;
WaitingForUserToResend: number;
CloseRequest: number;
WaitingForUserCompleted: number;
constructor(report: any) {
this.all = report.all;
this.UnChecked = report.UnChecked;
this.CheckedRequest = report.CheckedRequest;
this.CheckAgain = report.CheckAgain;
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
}
}
export class CompanyAllRequestsCountReportDtoRs {
blame: object;
claim: object;
constructor(blame: object, claim: object) {
this.blame = blame;
this.claim = claim; this.claim = claim;
this.blame = blame;
this.unifiedByPublicId = { total: unifiedTotal };
}
}
export class InsurerPerMonthReportRowDtoRs {
stDate: Date;
enDate: Date;
faLabel: string;
data: InsurerRequestsSummaryDtoRs;
constructor(
stDate: Date,
enDate: Date,
faLabel: string,
data: InsurerRequestsSummaryDtoRs,
) {
this.stDate = stDate;
this.enDate = enDate;
this.faLabel = faLabel;
this.data = data;
}
}
/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */
export class InsurerExpertWorkLogEntryDtoRs {
expertId: string;
fullName: string;
/**
* `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`).
* `damage_expert` = claim damage expert (`damage-expert` collection).
* Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here.
*/
expertKind: "expert" | "damage_expert";
/** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */
totalHandled: number;
/** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */
currentlyChecking: number;
/** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */
distinctFilesCheckedInPeriod: number;
constructor(
expertId: string,
fullName: string,
expertKind: "expert" | "damage_expert",
totalHandled: number,
currentlyChecking: number,
distinctFilesCheckedInPeriod: number,
) {
this.expertId = expertId;
this.fullName = fullName;
this.expertKind = expertKind;
this.totalHandled = totalHandled;
this.currentlyChecking = currentlyChecking;
this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod;
}
}
export class InsurerExpertWorkLogResponseDtoRs {
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) {
this.experts = experts;
}
}
export class InsurerExpertWorkLogPerMonthRowDtoRs {
stDate: Date;
enDate: Date;
faLabel: string;
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(
stDate: Date,
enDate: Date,
faLabel: string,
experts: InsurerExpertWorkLogEntryDtoRs[],
) {
this.stDate = stDate;
this.enDate = enDate;
this.faLabel = faLabel;
this.experts = experts;
} }
} }

View File

@@ -1,8 +1,12 @@
import { Controller, Get, UseGuards } from "@nestjs/common"; import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
@ApiTags("reports") @ApiTags("reports")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY) @Roles(RoleEnum.COMPANY)
@Controller("reports") @Controller("reports")
export class ReportsController { export class ReportsController {
constructor(private readonly reportsService: ReportsService) {} constructor(private readonly reportsService: ReportsService) {}
@Get("/report/requests") @Get("report/insurer/requests")
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) { @ApiOperation({
return await this.reportsService.getAllRequestsReportCount(actor, client); summary: "Insurer: claim/blame status buckets + unified file count",
description:
"Claim and blame counts use the same V2 bucket rules as expert panels (IN_PROGRESS grouping), scoped to the insurer JWT tenant. unifiedByPublicId.total counts distinct publicId values like GET expert-insurer/files.",
})
async getInsurerRequestsSummary(@CurrentUser() actor) {
return await this.reportsService.getInsurerRequestsSummary(actor);
} }
@Get("/report/perMonthRequests") @Get("report/insurer/per-month-requests")
async getAllRequestsReportPerMonth( @ApiOperation({
summary: "Insurer: same summary as report/insurer/requests, per calendar month",
description:
"Last five calendar months; each slice filters blame/claim rows by createdAt in that month, then claim buckets, blame buckets, and unified publicId count.",
})
async getInsurerRequestsSummaryPerMonth(@CurrentUser() actor) {
return await this.reportsService.getInsurerRequestsSummaryPerMonth(actor);
}
@Get("report/insurer/checked-requests")
@ApiOperation({
summary: "Insurer: summary filtered by optional createdAt range",
description:
"Same payload as report/insurer/requests. When from/to are omitted, all tenant files are included. When provided, only blame/claim rows whose createdAt falls in the range are counted (inclusive).",
})
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerRequestsSummaryByDateRange(
@CurrentUser() actor, @CurrentUser() actor,
@ClientKey() client, @Query("from") from?: string,
@Query("to") to?: string,
) { ) {
return await this.reportsService.getAllRequestsReportPerMonth( return await this.reportsService.getInsurerRequestsSummaryByDateRange(
actor, actor,
client, from,
to,
); );
} }
@Get("/report/checkedRequests") @Get("report/insurer/expert-work-log")
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) { @ApiOperation({
return await this.reportsService.getAllCheckedRequestsCount(actor, client); summary: "Insurer: per-expert work log (blame experts + damage experts)",
description:
"Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.",
})
async getInsurerExpertWorkLog(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLog(actor);
}
@Get("report/insurer/expert-work-log/per-month")
@ApiOperation({
summary: "Insurer: blame + damage expert work log, last five calendar months",
description:
"Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that months end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).",
})
async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor);
} }
} }

View File

@@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { ClientModule } from "src/client/client.module"; import { UsersModule } from "src/users/users.module";
import { ReportsController } from "./reports.controller"; import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
@@ -9,7 +9,7 @@ import { ReportsService } from "./reports.service";
imports: [ imports: [
RequestManagementModule, RequestManagementModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
ClientModule, UsersModule,
], ],
controllers: [ReportsController], controllers: [ReportsController],
providers: [ReportsService], providers: [ReportsService],

View File

@@ -1,413 +1,496 @@
import { Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { UserType } from "src/Types&Enums/userType.enum";
import { import {
CompanyAllRequestsCountReportDtoRs, blameCaseStatusToReportBucket,
DamageExpertAllRequestsCountReportDtoRs, claimCaseStatusToReportBucket,
ExpertAllRequestsCountReportDtoRs, initialBlameExpertReportBuckets,
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import {
blameCaseTouchesClient,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.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 { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import {
InsurerExpertWorkLogPerMonthRowDtoRs,
InsurerExpertWorkLogResponseDtoRs,
InsurerExpertWorkLogEntryDtoRs,
InsurerPerMonthReportRowDtoRs,
InsurerRequestsSummaryDtoRs,
} from "./dto/reports.dto"; } from "./dto/reports.dto";
type NormalizedActivity = {
expertId: string;
fileId: string;
eventType: ExpertFileActivityType;
occurredAt: Date;
};
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
constructor( constructor(
private readonly requestManagementDbService: RequestManagementDbService, private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly clientDbService: ClientDbService, private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) {} ) {}
async getAllCheckedRequestsCountFn(role: string, client: string) { private parseDateRange(from?: string, to?: string) {
const statuses = ["UnChecked", "CheckedRequest"]; const fromDate = from ? new Date(from) : undefined;
const data: Record<string, number> = { all: 0 }; const toDate = to ? new Date(to) : undefined;
const clientId = new Types.ObjectId(client);
for (const status of statuses) { if (fromDate && Number.isNaN(fromDate.getTime())) {
let count = 0; throw new BadRequestException("Invalid 'from' date");
if (role === "damage_expert") {
count = await this.claimRequestManagementDbService.countByFilter({
claimStatus: status,
userClientKey: clientId,
});
} else {
count = await this.requestManagementDbService.countByFilter({
blameStatus: status,
$or: [
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
],
});
}
data[status] = count;
data.all += count;
} }
return data; if (toDate && Number.isNaN(toDate.getTime())) {
throw new BadRequestException("Invalid 'to' date");
}
if (fromDate && toDate && fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return { fromDate, toDate };
} }
async getDateFilteredRequestByStatus( private isInDateRange(
role: string, value: unknown,
client: string, fromDate?: Date,
start: Date, toDate?: Date,
end: Date, ): boolean {
) { if (!fromDate && !toDate) return true;
const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"]; if (!value) return false;
const data: Record<string, number> = {}; const d = new Date(value as string | Date);
const clientId = new Types.ObjectId(client); if (Number.isNaN(d.getTime())) return false;
if (fromDate && d < fromDate) return false;
for (const status of statuses) { if (toDate && d > toDate) return false;
let blameCount = 0; return true;
let claimCount = 0;
if (role === "expert" || role === "company") {
blameCount = await this.requestManagementDbService.countByFilter({
blameStatus: status,
createdAt: { $gte: start, $lte: end },
$or: [
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
],
});
}
if (role === "damage_expert" || role === "company") {
claimCount = await this.claimRequestManagementDbService.countByFilter({
claimStatus: status,
userClientKey: clientId,
createdAt: { $gte: start, $lte: end },
});
}
data[status] = blameCount + claimCount;
}
return data;
} }
private isVisibleToClientType(client: any, actor: any): boolean { private publicIdKey(doc: Record<string, unknown>): string {
if (actor.userType === UserType.GENUINE) { return String(doc.publicId ?? doc._id ?? "");
return true;
}
if (
actor.userType === UserType.LEGAL &&
String(client._id) === actor.clientKey
) {
return true;
}
return false;
} }
private wasHandledByActor(request: any, actorSub: string): boolean { private buildClaimBucketsForInsurer(
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } }; rows: Record<string, unknown>[],
const actorChecker = request.actorsChecker as ActorCheckerEntry[]; clientKey: string,
dateFrom?: Date,
if (!Array.isArray(actorChecker)) { dateTo?: Date,
return false; ): Record<string, number> {
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows) {
if (!claimCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const st = String(doc.status ?? "");
const key = claimCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
} }
return buckets;
}
const matchingEntry = actorChecker.find( private buildBlameBucketsForInsurer(
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub, rows: Record<string, unknown>[],
); clientKey: string,
dateFrom?: Date,
return !!matchingEntry; dateTo?: Date,
): Record<string, number> {
const buckets = initialBlameExpertReportBuckets();
for (const doc of rows) {
if (!blameCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const st = String(doc.status ?? "");
const key = blameCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
return buckets;
} }
/** /**
* Filters claim requests using the same logic as expert-claim service * One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
* to ensure consistency between endpoints
*/ */
private async filterClaimRequestsForExpert( private countUnifiedByPublicId(
requests: any[], blameRows: Record<string, unknown>[],
actor: any, claimRows: Record<string, unknown>[],
): Promise<any[]> { clientKey: string,
const filteredRequests = []; dateFrom?: Date,
dateTo?: Date,
for (const r of requests) { ): number {
// For expert-initiated blame files, only show to the initiating expert const keys = new Set<string>();
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) { for (const doc of blameRows) {
if (String(r.blameFile.initiatedBy) !== actor.sub) { if (!blameCaseTouchesClient(doc, clientKey)) continue;
continue; // Skip if not the initiating expert if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
} const k = this.publicIdKey(doc);
// Expert-initiated claim files are always visible to the initiating expert if (k) keys.add(k);
filteredRequests.push(r);
continue;
}
const client = await this.clientDbService.findOne({
_id: r.userClientKey,
});
if (!client) {
this.logger.warn(
`Client not found for claim request with ID: ${r._id}. Skipping.`,
);
continue;
}
const specialHandlingStatuses = [
ReqClaimStatus.CheckAgain,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation,
];
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
}
}
} }
for (const doc of claimRows) {
return filteredRequests; if (!claimCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const k = this.publicIdKey(doc);
if (k) keys.add(k);
}
return keys.size;
} }
async getAllRequestsCountByRole(role: string, client: string, actor?: any) { private buildInsurerSummary(
if (role === "expert") { blames: Record<string, unknown>[],
const statuses = Object.values(ReqBlameStatus); claims: Record<string, unknown>[],
const data: Record<string, number> = { all: 0 }; clientKey: string,
dateFrom?: Date,
for (const status of statuses) { dateTo?: Date,
const filter = { ): InsurerRequestsSummaryDtoRs {
blameStatus: status, const claim = this.buildClaimBucketsForInsurer(
$or: [ claims,
{ clientKey,
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId( dateFrom,
client, dateTo,
), );
}, const blame = this.buildBlameBucketsForInsurer(
// { blames,
// "secondPartyDetails.secondPartyClient.clientId": clientKey,
// new Types.ObjectId(client), dateFrom,
// }, dateTo,
], );
}; const unifiedTotal = this.countUnifiedByPublicId(
blames,
const count = claims,
await this.requestManagementDbService.countByFilter(filter); clientKey,
data[status] = count; dateFrom,
data.all += count; dateTo,
} );
return new InsurerRequestsSummaryDtoRs(claim, blame, unifiedTotal);
return data;
}
if (role === "damage_expert") {
const statuses = Object.values(ReqClaimStatus);
const data: Record<string, number> = { all: 0 };
// For damage_expert, we need to apply the same filtering as expert-claim service
if (actor) {
// Fetch all requests with the statuses that expert-claim service shows
// This matches the statuses in getClaimRequestsListForExpert
const relevantStatuses = [
ReqClaimStatus.UnChecked,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
];
// Fetch all requests with relevant statuses (matching expert-claim query)
const allRequests =
await this.claimRequestManagementDbService.findAllByStatus({
claimStatus: { $in: relevantStatuses },
});
// Filter requests using the same logic as expert-claim service
const filteredRequests =
await this.filterClaimRequestsForExpert(allRequests, actor);
// Count by status from filtered results
for (const status of statuses) {
const count = filteredRequests.filter(
(r) => r.claimStatus === status,
).length;
data[status] = count;
data.all += count;
}
} else {
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
for (const status of statuses) {
const filter = {
claimStatus: status,
userClientKey: new Types.ObjectId(client),
};
const count =
await this.claimRequestManagementDbService.countByFilter(filter);
data[status] = count;
data.all += count;
}
}
return data;
}
// ✅ Company logic
if (role === "company") {
const blameStatuses = Object.values(ReqBlameStatus);
const claimStatuses = Object.values(ReqClaimStatus);
const blameData: Record<string, number> = { all: 0 };
const claimData: Record<string, number> = { all: 0 };
// Only count files where this company is FIRST party in Blame
for (const status of blameStatuses) {
const filter = {
blameStatus: status,
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
client,
),
};
const count =
await this.requestManagementDbService.countByFilter(filter);
blameData[status] = count;
blameData.all += count;
}
// Claims always use `userClientKey`
for (const status of claimStatuses) {
const filter = {
claimStatus: status,
userClientKey: new Types.ObjectId(client),
};
const count =
await this.claimRequestManagementDbService.countByFilter(filter);
claimData[status] = count;
claimData.all += count;
}
return { blameData, claimData };
}
return null;
} }
async getAllRequestsReportCount(actor, client) { async getInsurerRequestsSummary(actor: {
if (actor.role === "damage_expert") { clientKey?: string;
// Pass actor to apply filtering logic for damage_expert }): Promise<InsurerRequestsSummaryDtoRs> {
const data = await this.getAllRequestsCountByRole( const clientKey = requireActorClientKey(actor);
actor.role, const [blames, claims] = await Promise.all([
client, this.blameRequestDbService.find({}, { lean: true }),
actor, this.claimCaseDbService.find({}, { lean: true }),
); ]);
return new DamageExpertAllRequestsCountReportDtoRs(data); return this.buildInsurerSummary(
} else if (actor.role === "expert") { blames as Record<string, unknown>[],
const data = await this.getAllRequestsCountByRole(actor.role, client); claims as Record<string, unknown>[],
return new ExpertAllRequestsCountReportDtoRs(data); clientKey,
} else { );
// company
const damageExpertData = await this.getAllRequestsCountByRole(
"damage_expert",
client,
);
const expertData = await this.getAllRequestsCountByRole("expert", client);
return new CompanyAllRequestsCountReportDtoRs(
expertData,
damageExpertData,
);
}
} }
async getAllRequestsReportPerMonth(actor, client) { async getInsurerRequestsSummaryPerMonth(actor: {
const result = []; clientKey?: string;
}): Promise<InsurerPerMonthReportRowDtoRs[]> {
const clientKey = requireActorClientKey(actor);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
const blameRows = blames as Record<string, unknown>[];
const claimRows = claims as Record<string, unknown>[];
const result: InsurerPerMonthReportRowDtoRs[] = [];
const now = new Date(); const now = new Date();
if (actor.role === "company") { for (let i = 0; i < 5; i++) {
for (let i = 0; i < 5; i++) { const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); const monthEnd = new Date(
const monthEnd = new Date( now.getFullYear(),
now.getFullYear(), now.getMonth() - i + 1,
now.getMonth() - i + 1, 0,
0, 23,
23, 59,
59, 59,
59, );
); const label = monthStart.toLocaleDateString("fa-IR", {
const label = monthStart.toLocaleDateString("fa-IR", { month: "long",
month: "long", year: "numeric",
year: "numeric", });
});
const blameData = await this.getDateFilteredRequestByStatus( const data = this.buildInsurerSummary(
"expert", blameRows,
client, claimRows,
monthStart, clientKey,
monthEnd, monthStart,
); monthEnd,
const claimData = await this.getDateFilteredRequestByStatus( );
"damage_expert",
client,
monthStart,
monthEnd,
);
result.unshift({ result.unshift(
stDate: monthStart, new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
enDate: monthEnd, );
faLabel: label,
data: {
blame: blameData,
claim: claimData,
},
});
}
return result;
} else {
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const data = await this.getDateFilteredRequestByStatus(
actor.role,
client,
monthStart,
monthEnd,
);
result.unshift({
stDate: monthStart,
enDate: monthEnd,
faLabel: label,
data,
});
}
return result;
} }
return result;
} }
async getAllCheckedRequestsCount(actor, client) { async getInsurerRequestsSummaryByDateRange(
if (actor.role === "damage_expert" || actor.role === "expert") { actor: { clientKey?: string },
return this.getAllCheckedRequestsCountFn(actor.role, client); from?: string,
} else { to?: string,
const blame = await this.getAllCheckedRequestsCountFn("expert", client); ): Promise<InsurerRequestsSummaryDtoRs> {
const claim = await this.getAllCheckedRequestsCountFn( const clientKey = requireActorClientKey(actor);
"damage_expert", const { fromDate, toDate } = this.parseDateRange(from, to);
client,
); const [blames, claims] = await Promise.all([
return { blame, claim }; this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
return this.buildInsurerSummary(
blames as Record<string, unknown>[],
claims as Record<string, unknown>[],
clientKey,
fromDate,
toDate,
);
}
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
private replayExpertFileStates(
events: NormalizedActivity[],
cutoff: Date,
): Map<string, { checked: boolean; handled: boolean }> {
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
const filtered = events.filter((e) => e.occurredAt <= cutoff);
filtered.sort((a, b) => {
const t = a.occurredAt.getTime() - b.occurredAt.getTime();
if (t !== 0) return t;
return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`);
});
for (const e of filtered) {
const key = `${e.expertId}:${e.fileId}`;
const prev = stateByKey.get(key) ?? { checked: false, handled: false };
if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
}
stateByKey.set(key, prev);
} }
return stateByKey;
}
private snapshotCountsForExpert(
stateByKey: Map<string, { checked: boolean; handled: boolean }>,
expertId: string,
): { totalHandled: number; currentlyChecking: number } {
let totalHandled = 0;
let currentlyChecking = 0;
const prefix = `${expertId}:`;
for (const [key, st] of stateByKey.entries()) {
if (!key.startsWith(prefix)) continue;
if (st.handled) totalHandled++;
else if (st.checked) currentlyChecking++;
}
return { totalHandled, currentlyChecking };
}
private distinctFilesWithCheckInWindow(
events: NormalizedActivity[],
expertId: string,
from: Date,
to: Date,
): number {
const files = new Set<string>();
for (const e of events) {
if (e.expertId !== expertId) continue;
if (e.eventType !== ExpertFileActivityType.CHECKED) continue;
if (e.occurredAt < from || e.occurredAt > to) continue;
files.add(e.fileId);
}
return files.size;
}
private async loadTenantExpertsAndActivities(clientKey: string): Promise<{
events: NormalizedActivity[];
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>;
}> {
const tenantOid = new Types.ObjectId(clientKey);
const tenantStr = String(tenantOid);
/**
* `clientKey` is sometimes stored as ObjectId, sometimes as string — match both.
* Activity is loaded for the whole tenant so claim (V2) damage experts still appear
* even if a roster row was missed by shape mismatches; we then merge `_id` lookups.
*/
const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([
this.expertDbService.findAll({
$or: [{ clientKey: tenantStr }, { clientKey: tenantOid }],
} as never),
this.damageExpertDbService.findAll({
$or: [{ clientKey: tenantOid }, { clientKey: tenantStr }],
} as never),
this.expertFileActivityDbService.findByTenant(tenantOid),
]);
const expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}> = [];
const seenIds = new Set<string>();
const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => {
if (seenIds.has(id)) return;
seenIds.add(id);
expertRows.push({ id, fullName, kind });
};
for (const e of blamePanelExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "expert");
}
for (const e of damageExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "damage_expert");
}
const activityExpertIds = [
...new Set(
rawEvents
.map((row) => String(row.expertId))
.filter((id) => Types.ObjectId.isValid(id)),
),
].filter((id) => !seenIds.has(id));
if (activityExpertIds.length > 0) {
const oids = activityExpertIds.map((id) => new Types.ObjectId(id));
const [extraDamages, extraExperts] = await Promise.all([
this.damageExpertDbService.findAll({ _id: { $in: oids } } as never),
this.expertDbService.findAll({ _id: { $in: oids } } as never),
]);
for (const d of extraDamages) {
const id = String((d as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() ||
(d as any).email ||
id;
pushRow(id, fullName, "damage_expert");
}
for (const x of extraExperts) {
const id = String((x as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() ||
(x as any).email ||
id;
pushRow(id, fullName, "expert");
}
}
const events: NormalizedActivity[] = rawEvents.map((row) => ({
expertId: String(row.expertId),
fileId: String(row.fileId),
eventType: row.eventType,
occurredAt:
row.occurredAt instanceof Date
? row.occurredAt
: new Date(row.occurredAt as string | Date),
}));
return { events, expertRows };
}
private buildWorkLogEntries(
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>,
events: NormalizedActivity[],
cutoff: Date,
checkedWindow: { from: Date; to: Date },
): InsurerExpertWorkLogEntryDtoRs[] {
const state = this.replayExpertFileStates(events, cutoff);
return expertRows.map((row) => {
const snap = this.snapshotCountsForExpert(state, row.id);
return new InsurerExpertWorkLogEntryDtoRs(
row.id,
row.fullName,
row.kind,
snap.totalHandled,
snap.currentlyChecking,
this.distinctFilesWithCheckInWindow(
events,
row.id,
checkedWindow.from,
checkedWindow.to,
),
);
});
}
async getInsurerExpertWorkLog(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const now = new Date();
const epoch = new Date(0);
const entries = this.buildWorkLogEntries(expertRows, events, now, {
from: epoch,
to: now,
});
return new InsurerExpertWorkLogResponseDtoRs(entries);
}
async getInsurerExpertWorkLogPerMonth(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = [];
const now = new Date();
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const experts = this.buildWorkLogEntries(
expertRows,
events,
monthEnd,
{ from: monthStart, to: monthEnd },
);
result.unshift(
new InsurerExpertWorkLogPerMonthRowDtoRs(
monthStart,
monthEnd,
label,
experts,
),
);
}
return result;
} }
} }

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
/**
* Computed on GET blame v2 for the current viewer — no DB field on BlameRequest.
*/
export class ClaimCreationHintDto {
@ApiProperty({
description:
"True if a v2 claim case already exists for this blame (by blameRequestId).",
})
hasClaim: boolean;
@ApiProperty({
description:
"True when this viewer is the damaged party, blame is COMPLETED, no claim exists yet, " +
"and for THIRD_PARTY all parties have signed and accepted the expert decision.",
})
shouldGuideToCreateClaim: boolean;
}

View File

@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
} }
export class DescriptionDto { export class DescriptionDto {
@ApiProperty() @ApiProperty({ description: "Accident description (required for all types)" })
desc: string; desc: string;
// CAR_BODY specific fields @ApiPropertyOptional({
@ApiPropertyOptional({ description: "CAR_BODY only. Ignored for THIRD_PARTY.",
description: "Date of accident (for CAR_BODY type only)", example: "2025-12-08",
example: "2025-12-08"
}) })
accidentDate?: Date; accidentDate?: Date;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "14:30" example: "14:30",
}) })
accidentTime?: string; accidentTime?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: WeatherCondition, enum: WeatherCondition,
description: "Weather condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: WeatherCondition.CLEAR example: WeatherCondition.CLEAR,
}) })
weatherCondition?: WeatherCondition; weatherCondition?: WeatherCondition;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: RoadCondition, enum: RoadCondition,
description: "Road condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: RoadCondition.DRY example: RoadCondition.DRY,
}) })
roadCondition?: RoadCondition; roadCondition?: RoadCondition;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: LightCondition, enum: LightCondition,
description: "Light condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: LightCondition.DAYLIGHT example: LightCondition.DAYLIGHT,
}) })
lightCondition?: LightCondition; lightCondition?: LightCondition;
} }
/**
* THIRD_PARTY description step: only desc is accepted.
*/
export class ThirdPartyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
}
/**
* CAR_BODY description step: desc + accident date/time and conditions required.
*/
export class CarBodyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
@ApiProperty({ description: "Date of accident", example: "2025-12-08" })
accidentDate: Date;
@ApiProperty({ description: "Time of accident", example: "14:30" })
accidentTime: string;
@ApiProperty({ enum: WeatherCondition, example: WeatherCondition.CLEAR })
weatherCondition: WeatherCondition;
@ApiProperty({ enum: RoadCondition, example: RoadCondition.DRY })
roadCondition: RoadCondition;
@ApiProperty({ enum: LightCondition, example: LightCondition.DAYLIGHT })
lightCondition: LightCondition;
}
export class FirstPartyDetail { export class FirstPartyDetail {
// @ApiProperty({ type: String }) // @ApiProperty({ type: String })
firstPartyId?: Types.ObjectId; firstPartyId?: Types.ObjectId;

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto"; import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto; firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto; firstPartyDescription: DescriptionDto;
} }

View File

@@ -0,0 +1,30 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import {
CarBodyFormDto,
DescriptionDto,
InitialFormDto,
} from "./create-request-management.dto";
/**
* V2 (IN_PERSON): single expert description/location for the scene.
*/
export class ExpertCompleteCarBodyFormV2Dto {
@ApiProperty({
description: "First party phone number",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiProperty({ type: InitialFormDto })
firstPartyInitialForm: InitialFormDto;
@ApiProperty({ type: CarBodyFormDto })
carBodyForm: CarBodyFormDto;
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: DescriptionDto })
expertDescription: DescriptionDto;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { LocationDto } from "./create-request-management.dto";
/**
* V2 (IN_PERSON): one scene location provided by expert.
*/
export class ExpertCompleteLocationV2Dto {
@ApiProperty({ type: LocationDto })
location: LocationDto;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { LocationDto } from "./create-request-management.dto";
export class ExpertCompleteLocationsDto {
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiPropertyOptional({ type: LocationDto })
secondPartyLocation?: LocationDto;
}

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto } from "./create-request-management.dto"; import { InitialFormDto } from "./create-request-management.dto";
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
plate: AddPlateDto; plate: AddPlateDto;
@ApiProperty({ type: LocationDto })
location: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
description: DescriptionDto; description: DescriptionDto;
} }
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto; firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto; firstPartyDescription: DescriptionDto;

View File

@@ -0,0 +1,50 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { DescriptionDto, InitialFormDto } from "./create-request-management.dto";
/**
* V2 (IN_PERSON): second party inputs without separate description/location.
*/
export class ExpertSecondPartyInfoV2Dto {
@ApiProperty({
description: "Second party phone number",
example: "09123456789",
})
phoneNumber: string;
@ApiProperty({ type: InitialFormDto })
initialForm: InitialFormDto;
@ApiProperty({ type: AddPlateDto })
plate: AddPlateDto;
}
/**
* V2 (IN_PERSON): single expert description shared for the whole scene.
*/
export class ExpertCompleteThirdPartyFormV2Dto {
@ApiProperty({
description: "First party phone number",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiProperty({ type: InitialFormDto })
firstPartyInitialForm: InitialFormDto;
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: ExpertSecondPartyInfoV2Dto })
secondParty: ExpertSecondPartyInfoV2Dto;
@ApiProperty({ type: DescriptionDto })
expertDescription: DescriptionDto;
@ApiProperty({
description:
"Phone number of the guilty party. Must match either first or second party phone number.",
example: "09123456789",
})
guiltyPartyPhoneNumber: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
/**
* Body for field expert uploading a party's signature for expert-initiated IN_PERSON blame.
*/
export class ExpertUploadPartySignatureDto {
@ApiProperty({
enum: ["FIRST", "SECOND"],
description: "Which party's signature is being uploaded. CAR_BODY: use FIRST only. THIRD_PARTY: FIRST and SECOND.",
})
partyRole: "FIRST" | "SECOND";
@ApiProperty({
default: true,
description: "Party accepts the blame agreement (true). Set false if party rejects.",
})
isAccept: boolean;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString } from "class-validator";
export class CreateRegistrarInitiatedFileDto {
@ApiProperty({ enum: ["THIRD_PARTY", "CAR_BODY"], example: "THIRD_PARTY" })
@IsString()
@IsIn(["THIRD_PARTY", "CAR_BODY"])
type: "THIRD_PARTY" | "CAR_BODY";
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
export class SendExpertInitiatedLinkV2Dto {
@ApiProperty({
description:
"Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty",
example: "requestManagement/firstParty",
})
@IsString()
@IsNotEmpty()
frontendRoute: string;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
/**
* Body for field expert to request OTP send to one or two parties for expert-initiated IN_PERSON blame.
* Uses the same SMS flow as /user/send-otp. After this, parties receive SMS with OTP; expert collects and calls verify-party-otps.
*/
export class SendPartyOtpsDto {
@ApiProperty({
description: "First party phone number (e.g. 09123456789). SMS with OTP will be sent to this number.",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiPropertyOptional({
description: "Second party phone number. Required when blame type is THIRD_PARTY. SMS with OTP will be sent to this number.",
example: "09187654321",
})
secondPartyPhoneNumber?: string;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class UserResendResponseDto { export class UserResendResponseDto {
@ApiProperty({ @ApiProperty({
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
description: "Whether the user has completed uploading all requested items", description: "Whether the user has completed uploading all requested items",
}) })
completed: boolean; completed: boolean;
@ApiPropertyOptional({
description:
"Per-item UI hint: document_camera (open camera), voice, video, or text (multiline field).",
example: [
{ item: "drivingLicense", inputKind: "document_camera" },
{ item: "description", inputKind: "text" },
],
})
requestedItemsUi?: { item: string; inputKind: string }[];
@ApiPropertyOptional({
description: "Requested items this party has not yet satisfied (partial resend).",
example: ["carCertificate"],
})
remainingItems?: string[];
} }
export class UserResendUploadResponseDto { export class UserResendUploadResponseDto {

Some files were not shown because too many files have changed in this diff Show More