1
0
forked from Yara724/api

Compare commits

..

264 Commits

Author SHA1 Message Date
SepehrYahyaee
61684156c6 YARA-1133 2026-07-27 14:00:54 +03:30
SepehrYahyaee
588a92c4b4 YARA-1165 2026-07-27 11:46:03 +03:30
SepehrYahyaee
c94dd27a96 YARA-1164 2026-07-27 11:38:54 +03:30
SepehrYahyaee
e4c3b7a16a YARA-1162 2026-07-27 10:15:46 +03:30
SepehrYahyaee
4b9d946bfd YARA-1154 2026-07-27 09:31:07 +03:30
SepehrYahyaee
9828aee8af YARA-1136 2026-07-26 11:35:21 +03:30
778544c321 Merge pull request 'YARA-1147' (#217) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#217
2026-07-25 12:32:22 +03:30
SepehrYahyaee
3afff67336 YARA-1147 2026-07-25 12:31:54 +03:30
793ff639ba Merge pull request 'Added insurer capabilities for super-admin' (#216) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#216
2026-07-25 11:55:42 +03:30
SepehrYahyaee
ec15cff557 Added insurer capabilities for super-admin 2026-07-25 11:55:00 +03:30
fd42adf9d6 Merge pull request 'Added inner parts to APIs for expert claim' (#215) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#215
2026-07-25 11:10:38 +03:30
SepehrYahyaee
4272790fad Added inner parts to APIs for expert claim 2026-07-25 11:10:02 +03:30
ac65cdb77b Merge pull request 'lookups and inquiries in lookups added' (#214) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#214
2026-07-25 11:04:42 +03:30
8430c68e3a lookups and inquiries in lookups added 2026-07-25 11:03:58 +03:30
49fe548215 Merge pull request 'Fixed v5 file maker approval field' (#213) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#213
2026-07-25 10:25:10 +03:30
SepehrYahyaee
777eae1028 Fixed v5 file maker approval field 2026-07-25 10:24:34 +03:30
b67dd733cd Merge pull request 'Fixed upload documents counting for v4' (#212) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#212
2026-07-25 09:53:07 +03:30
SepehrYahyaee
4818f73252 Fixed upload documents counting for v4 2026-07-25 09:52:42 +03:30
cc8a5354c7 Merge pull request 'v4 bug fixed' (#211) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#211
2026-07-25 09:29:42 +03:30
SepehrYahyaee
2d5ade33d2 v4 bug fixed 2026-07-25 09:29:12 +03:30
b73c92c21f Merge pull request 'Fixed Bugs: 1) V4 incorrect advancement to file maker approval state, 2) V4 race condition for finishing file maker steps' (#210) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#210
2026-07-23 13:44:05 +03:30
f9f462d47b Fixed Bugs: 1) V4 incorrect advancement to file maker approval state, 2) V4 race condition for finishing file maker steps 2026-07-23 13:43:37 +03:30
c3eb36dc41 Merge pull request 'Fixed v4 sign' (#209) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#209
2026-07-22 17:37:00 +03:30
SepehrYahyaee
f75ecf5c2c Fixed v4 sign 2026-07-22 17:36:29 +03:30
75c4cb6a05 Merge pull request 'Fixed v4/v5 flow' (#208) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#208
2026-07-22 17:22:55 +03:30
SepehrYahyaee
7a4277f8b2 Fixed v4/v5 flow 2026-07-22 17:22:27 +03:30
e50bc78344 Merge pull request 'Fixed sign' (#207) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#207
2026-07-22 15:47:02 +03:30
SepehrYahyaee
2c1cd93dd0 Fixed sign 2026-07-22 15:46:33 +03:30
ffd44df718 Merge pull request 'Fix v2 flow sign of second party' (#206) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#206
2026-07-22 15:35:53 +03:30
SepehrYahyaee
64865b70f2 Fix v2 flow sign of second party 2026-07-22 15:35:14 +03:30
c207c30be9 Merge pull request 'Fixed v2 flow' (#205) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#205
2026-07-22 15:10:00 +03:30
SepehrYahyaee
fcb169e9ac Fixed v2 flow 2026-07-22 15:09:34 +03:30
1867292499 Merge pull request 'Fixed v2 flow' (#204) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#204
2026-07-22 14:53:33 +03:30
SepehrYahyaee
2cc96f6132 Fixed v2 flow 2026-07-22 14:52:51 +03:30
4d5b91d4fe Merge pull request 'update the fanavaran for both tejarat no and parsian clients , dont forget about the env files' (#203) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#203
2026-07-20 16:30:15 +03:30
8ba97537a4 update the fanavaran for both tejarat no and parsian clients , dont forget about the env files 2026-07-20 16:28:25 +03:30
70160543a2 Merge pull request 'Fixed v2 mirror' (#202) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#202
2026-07-20 11:45:26 +03:30
SepehrYahyaee
8460c86820 Fixed v2 mirror 2026-07-20 11:44:50 +03:30
61f4181065 Merge pull request 'BUG fix of status' (#201) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#201
2026-07-19 16:51:15 +03:30
SepehrYahyaee
4058cb4a61 BUG fix of status 2026-07-19 16:50:45 +03:30
b2ad43d050 Merge pull request 'YARA-1020' (#200) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#200
2026-07-19 16:35:15 +03:30
SepehrYahyaee
9fc4ad9931 YARA-1020 2026-07-19 16:34:44 +03:30
213cdd765b Merge pull request 'YARA-985' (#199) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#199
2026-07-19 11:47:12 +03:30
SepehrYahyaee
06d69aa4d0 YARA-985 2026-07-19 11:44:15 +03:30
318a5c74dd Merge pull request 'removed guard' (#198) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#198
2026-07-18 16:14:59 +03:30
SepehrYahyaee
7241ae3270 removed guard 2026-07-18 16:14:27 +03:30
1f4a520145 Merge pull request 'Removed the guard of accidentWay' (#197) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#197
2026-07-18 16:03:55 +03:30
SepehrYahyaee
59a1b9064e Removed the guard of accidentWay 2026-07-18 16:03:32 +03:30
0420eda35f Merge pull request 'Edited 2 APIs names' (#196) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#196
2026-07-18 15:28:27 +03:30
SepehrYahyaee
482d2b01f1 Edited API route 2026-07-18 15:27:23 +03:30
SepehrYahyaee
04d7966776 Changed API name of v2 blame mirror 2026-07-18 15:25:59 +03:30
b129c1ef9b Merge pull request 'YARA-1061, Fixed v3 mirror flow' (#195) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#195
2026-07-18 15:02:18 +03:30
SepehrYahyaee
a1fca82cb2 Fixed v3 mirror flow 2026-07-18 15:01:13 +03:30
SepehrYahyaee
5b114c2069 YARA-1061 2026-07-18 14:30:26 +03:30
5e4897f609 Fix empty Rocket.Chat notify (Woodpecker ${} escaping) 2026-07-15 17:06:27 +03:30
a79c3ca05f Fix git pull SSH in pipeline (host keys + known_hosts) 2026-07-15 16:59:46 +03:30
36fa1c552e Allow git in bind-mounted workspace (safe.directory) 2026-07-15 16:43:31 +03:30
9742fecc11 Update .woodpecker.yml 2026-07-15 16:35:40 +03:30
8007c30efd Fix path typo of workspace 2026-07-15 16:26:06 +03:30
144f8f3e2a Update .woodpecker.yml docker repositories 2026-07-15 16:05:01 +03:30
8674dd8762 Merge pull request 'Fixed v4/v5 car capture flow' (#194) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#194
2026-07-15 16:00:01 +03:30
SepehrYahyaee
f39c50aeb0 Fixed v4/v5 car capture flow 2026-07-15 15:59:21 +03:30
2fda740171 Update .woodpecker.yml 2026-07-15 15:54:28 +03:30
72e5bd616c Update .woodpecker.yml 2026-07-15 15:51:29 +03:30
4b41a60f64 Add .woodpecker.yml 2026-07-15 15:47:53 +03:30
9310285bd4 Merge pull request 'FIX v5 flow' (#193) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#193
2026-07-15 14:57:38 +03:30
SepehrYahyaee
2a8b66bc16 FIX v5 flow 2026-07-15 14:56:58 +03:30
9168a6bdcd Merge pull request 'Added fonts for PDF' (#192) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#192
2026-07-15 13:24:17 +03:30
SepehrYahyaee
057bedeb0c Added fonts for PDF 2026-07-15 13:23:43 +03:30
808a3b8526 Merge pull request 'YARA-994 and fixed metal plate bug' (#191) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#191
2026-07-15 10:34:34 +03:30
SepehrYahyaee
da7a4f8890 YARA-994 and fixed metal plate bug 2026-07-15 10:33:50 +03:30
21e55012be Merge pull request 'Fixed file maker retrieving files' (#190) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#190
2026-07-14 14:31:20 +03:30
SepehrYahyaee
385757c3a0 Fixed file maker retrieving files 2026-07-14 14:30:41 +03:30
a1b122a33b Merge pull request 'Fixed file maker view files error' (#189) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#189
2026-07-14 13:53:02 +03:30
SepehrYahyaee
aec9e76918 Fixed file maker view files error 2026-07-14 13:52:12 +03:30
7c59c2407e Merge pull request 'YARA-1115, YARA-1117, YARA-1119' (#188) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#188
2026-07-14 12:07:20 +03:30
SepehrYahyaee
168e52a475 YARA-1117 and fixed completed status after in person visit has been called 2026-07-14 11:51:52 +03:30
SepehrYahyaee
4aa6e03afb YARA-1119 2026-07-14 11:32:03 +03:30
SepehrYahyaee
36a34e27b3 YARA-1115 2026-07-14 11:23:39 +03:30
SepehrYahyaee
6387ebaed0 Fixed a bug where file makers would be able to view v4 files as well as v5 ones 2026-07-14 10:19:30 +03:30
22a5990934 Merge pull request 'Fixed blame status after v4/v5 flows gets completed' (#187) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#187
2026-07-14 10:08:32 +03:30
SepehrYahyaee
e5de99adde Fixed blame status after v4/v5 flows gets completed 2026-07-14 10:07:00 +03:30
c7fd2a6b33 Merge pull request 'YARA-1110' (#186) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#186
2026-07-13 11:54:28 +03:30
SepehrYahyaee
5595083e86 YARA-1110 2026-07-13 11:53:47 +03:30
2296fa5d86 Merge pull request 'YARA-1094, YARA-1095, YARA-1096' (#185) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#185
2026-07-12 14:08:38 +03:30
SepehrYahyaee
72dec7a917 YARA-1094, YARA-1095, YARA-1096 2026-07-12 14:07:27 +03:30
67019851de Merge pull request 'YARA-982, YARA-1062, YARA-1069' (#184) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#184
2026-07-12 11:45:49 +03:30
SepehrYahyaee
c955deda5c YARA-1069 2026-07-12 11:44:32 +03:30
SepehrYahyaee
9b83db882b YARA-982 2026-07-12 11:27:58 +03:30
SepehrYahyaee
bced6a0ec7 YARA-1062 2026-07-12 11:11:50 +03:30
5d1110b6e9 Merge pull request 'YARA-947, YARA-986, YARA-1038' (#183) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#183
2026-07-11 17:52:42 +03:30
SepehrYahyaee
a7fe04c032 YARA-986 2026-07-11 17:51:14 +03:30
SepehrYahyaee
0dcb2cf2ca YARA-947, YARA-1038 2026-07-11 15:34:01 +03:30
8b125af4e7 Merge pull request 'YARA-914, YARA-917, YARA-923, YARA-937, YARA-957, YARA-1056, YARA-1061' (#182) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#182
2026-07-11 13:20:00 +03:30
1559a40213 merge upstream 2026-07-11 13:17:55 +03:30
SepehrYahyaee
54ae82aa38 YARA-1056 2026-07-11 13:16:14 +03:30
SepehrYahyaee
7a3ddcc7be YARA-937 2026-07-11 12:23:00 +03:30
SepehrYahyaee
da3f57870e YARA-917 2026-07-11 12:00:11 +03:30
ac7ee941b8 Merge pull request 'main' (#181) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#181
2026-07-11 11:40:45 +03:30
5d005d5eee env example 2026-07-11 11:39:52 +03:30
b65d9bfe81 driverId problem fixed , also added a captcha required env called : CAPTCHA_ENABLED= to disable or enable CAPTCHA in development 2026-07-11 11:39:36 +03:30
SepehrYahyaee
04f51167c2 YARA-1061 2026-07-11 11:38:49 +03:30
SepehrYahyaee
2c8fd3960f YARA-957 2026-07-11 11:25:42 +03:30
SepehrYahyaee
e59058520c YARA-914, YARA-923 2026-07-11 11:16:16 +03:30
80122e7772 Merge pull request 'main' (#180) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#180
2026-07-07 18:53:35 +03:30
f409d78ede merge upstream 2026-07-07 18:53:01 +03:30
Soheil Hajizadeh
24340eb810 claim request management change 2026-07-07 18:52:07 +03:30
41d1de77eb Merge pull request 'main' (#179) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#179
2026-07-07 17:30:48 +03:30
0aadc64cd3 merge upstream 2026-07-07 17:30:23 +03:30
Soheil Hajizadeh
1e6c36cbd4 lookup of person role added + inquiry by unique identifier + put driver id in payload 2026-07-07 17:29:48 +03:30
ae23a10d33 Merge pull request 'main' (#178) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#178
2026-07-07 13:47:07 +03:30
1c6678d8e4 merge upstream 2026-07-07 13:46:27 +03:30
Soheil Hajizadeh
ad5ff28be4 fanavaran damage case updated 2026-07-07 13:42:06 +03:30
1fe2c77c70 Merge pull request 'main' (#177) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#177
2026-07-05 15:49:53 +03:30
45a51f2c24 merge upstream 2026-07-05 15:47:59 +03:30
Soheil Hajizadeh
0514548136 policyCINumber added to the payload 2026-07-05 15:45:39 +03:30
5b4cc0560f Merge pull request 'V4: add WAITING_FOR_FILE_REVIEWER claim status; fix select-outer-parts for split flow' (#176) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#176
2026-07-05 15:16:43 +03:30
e9c02811f7 V4: add WAITING_FOR_FILE_REVIEWER claim status; fix select-outer-parts for split flow
- Add ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER (V4 split flow only)
- Set claim status to WAITING_FOR_FILE_REVIEWER when FileMaker uploads
  the last required document (v3InPersonFlow path), replacing the old
  behaviour that incorrectly auto-advanced to SELECT_OUTER_PARTS
- advanceV3ClaimToOuterPartsIfReady: also allow canAdvance when
  claimCase.status === WAITING_FOR_FILE_REVIEWER so the FileReviewer
  can call select-outer-parts after submitting accident fields
- Add WAITING_FOR_FILE_REVIEWER to CLAIM_USER_PHASE (unified-file-status)
  so the file still resolves to IN_PROGRESS in the unified status report
- Add WAITING_FOR_FILE_REVIEWER to CLAIM_IN_PROGRESS_STATUSES
  (expert-panel-status-report) for the expert report bucket
- Add WAITING_FOR_FILE_REVIEWER to claimInHandling set
  (expert-insurer.service) so insurer stats count these correctly
2026-07-05 15:13:19 +03:30
8ab49c9a35 Merge pull request 'Fixed reviewer flow' (#175) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#175
2026-07-05 11:47:11 +03:30
f0cd4461a8 Fixed reviewer flow 2026-07-05 11:46:37 +03:30
3205a89611 Merge pull request 'Fixed GET APIs for FileMaker and FileReviewer getting their own files' (#174) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#174
2026-07-05 11:35:48 +03:30
14bc075521 Fixed GET APIs for FileMaker and FileReviewer getting their own files 2026-07-05 11:34:51 +03:30
1fe66b2d91 Merge pull request 'Adding file makers and file reviewers to global roles' (#173) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#173
2026-07-04 14:21:17 +03:30
033a853b51 Adding file makers and file reviewers to global roles 2026-07-04 12:54:47 +03:30
f05891a112 Merge pull request 'Fixed outer parts flow bug in v4' (#172) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#172
2026-07-04 10:29:10 +03:30
7641c56440 Fixed outer parts flow bug in v4 2026-07-04 10:28:20 +03:30
ae83100ef4 Merge pull request 'Added roles for GET car parts APIs' (#171) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#171
2026-07-02 13:17:37 +03:30
9e2cf9bcdf Added roles for GET car parts APIs 2026-07-02 13:17:04 +03:30
ced08fc1f7 Merge pull request 'fix it' (#170) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#170
2026-07-01 18:06:07 +03:30
d525b8dd0d fix it 2026-07-01 18:05:09 +03:30
b43f1a86dc Merge pull request 'Added blame Id for claims' (#169) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#169
2026-07-01 17:45:21 +03:30
SepehrYahyaee
5df39b502e Added blame Id for claims 2026-07-01 17:44:44 +03:30
49564cc1c6 Merge pull request 'Fixed statuses' (#168) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#168
2026-07-01 17:22:34 +03:30
SepehrYahyaee
29939eee20 Fixed statuses 2026-07-01 17:21:54 +03:30
ae789323d8 Merge pull request 'FIxed file reviewer bugs' (#167) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#167
2026-07-01 16:56:03 +03:30
SepehrYahyaee
6be9ff16e1 FIXED FILE REVIEWER GET ALL 2026-07-01 16:54:24 +03:30
SepehrYahyaee
0c5a2fe38b Fixed accident-details bug 2026-07-01 15:58:45 +03:30
5ef8310cb0 Merge pull request 'main' (#166) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#166
2026-07-01 15:14:56 +03:30
d2cb9444f3 merge upstream 2026-07-01 15:13:52 +03:30
67471fb9ce fanavaran stage by stage implemented i am so bored to send smart commit sorry MR sina 2026-07-01 15:13:22 +03:30
569e7592ec Merge pull request 'YARA-1025, YARA-1058, YARA-1075, YARA-1076' (#165) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#165
2026-07-01 12:25:07 +03:30
SepehrYahyaee
e2a9232523 YARA-1058 2026-07-01 12:23:34 +03:30
SepehrYahyaee
dc006735ba Duplicated capture-requirements endpoint for file-maker 2026-07-01 12:15:22 +03:30
SepehrYahyaee
f92cee0575 YARA-1075 2026-07-01 12:11:19 +03:30
SepehrYahyaee
493be68b80 YARA-1025 2026-07-01 12:04:39 +03:30
SepehrYahyaee
edf027acd3 YARA-1076 2026-07-01 12:00:29 +03:30
0698338d4c Merge pull request 'Access new roles' (#164) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#164
2026-07-01 11:11:55 +03:30
SepehrYahyaee
f3c7f6a7e0 Access new roles 2026-07-01 11:11:27 +03:30
607472cd89 Merge pull request 'Added fileMaker and fileReviewer for new flow' (#163) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#163
2026-06-30 13:54:59 +03:30
SepehrYahyaee
65e7476642 Added fileMaker and fileReviewer for new flow 2026-06-30 13:54:21 +03:30
9068765c25 Merge pull request 'main' (#162) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#162
2026-06-30 11:52:40 +03:30
99c819caeb feat(fanavaran): add auth token script
Why:
- Manual curl token setup was error-prone and allowed stale appToken reuse.

Changes:
- Add a script that accepts tejaratno or parsian, calls GetAppToken, then Login.
- Document the script flow and fill known curl variables from the codebase.

Impact:
- Users can generate fresh Fanavaran tokens without manually copying multi-step curl commands.
2026-06-30 11:51:40 +03:30
8d396762a2 fix(fanavaran): select latest active policy by end date
Why:
- Fanavaran policy inquiry response order is inconsistent, so selecting the last item can choose an old or expired policy.

Changes:
- Select the policy with the latest Jalali EndDate.
- Reject empty policy responses, expired latest policies, and latest policies without a valid PolicyId.
- Stop Fanavaran submission when PolicyId cannot be resolved.

Impact:
- Fanavaran submit now fails clearly instead of continuing with PolicyId: null.
2026-06-30 11:51:11 +03:30
f3686575ca Merge pull request 'Fix CAR_GREEN_CARD upload error' (#161) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#161
2026-06-28 16:59:44 +03:30
SepehrYahyaee
b9fe1bfd52 Fix CAR_GREEN_CARD upload error 2026-06-28 16:58:53 +03:30
6eca1f5dad Merge pull request 'YARA-1061, YARA-1072, YARA-1073, YARA-1074' (#160) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#160
2026-06-28 16:05:50 +03:30
SepehrYahyaee
6477778835 YARA-1074 2026-06-28 16:04:25 +03:30
SepehrYahyaee
4552450fbc YARA-1073 2026-06-28 15:53:34 +03:30
SepehrYahyaee
f7f7f4548d YARA-1061 2026-06-28 15:40:35 +03:30
SepehrYahyaee
220b39ea5a YARA-1072 2026-06-28 15:04:18 +03:30
4a045f564c Merge pull request 'Fix CAR_GREEN_CARD place to upload for v3' (#159) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#159
2026-06-28 14:22:10 +03:30
SepehrYahyaee
d3249e9854 Fix CAR_GREEN_CARD place to upload for v3 2026-06-28 14:21:08 +03:30
7e597db423 Merge pull request 'logged vehicle usage code' (#158) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#158
2026-06-27 17:51:29 +03:30
8303bf4467 logged vehicle usage code 2026-06-27 17:49:59 +03:30
ebe8671bd3 Merge pull request 'main' (#157) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#157
2026-06-27 16:51:30 +03:30
5022178569 merge upstream 2026-06-27 16:51:10 +03:30
2fbf40ef19 log the my policies 2026-06-27 16:50:57 +03:30
dca9e1f8d4 Merge pull request 'PDF generation + mismatched data fixes' (#156) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#156
2026-06-27 16:44:25 +03:30
SepehrYahyaee
8f8ed8a94e YARA-1062 2026-06-27 14:44:01 +03:30
SepehrYahyaee
3c7a656cd7 Fixed mismatched insurance number getting saved 2026-06-27 13:04:22 +03:30
87ece0d89d Merge pull request 'main' (#155) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#155
2026-06-27 10:57:23 +03:30
7af3f3627a merge upstream 2026-06-27 10:53:49 +03:30
7e1ae328ac updated the accident cause id to default value 6 2026-06-27 10:53:10 +03:30
094d9048ba merge upstream 2026-06-27 09:30:55 +03:30
9afb6a8a6e Merge pull request 'main' (#154) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#154
2026-06-23 18:24:40 +03:30
2df25e26bb merge upstream 2026-06-23 18:24:15 +03:30
c7fb6174a0 lookups update per client 2026-06-23 18:23:09 +03:30
136b22fd81 Merge pull request 'main' (#153) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#153
2026-06-23 17:44:03 +03:30
16cdf2e7b0 merge upstream 2026-06-23 17:43:42 +03:30
488c9180af address hardcoded 2026-06-23 17:43:24 +03:30
75c556a013 Merge pull request 'main' (#152) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#152
2026-06-23 16:00:15 +03:30
0f53bfabf5 merge upstream 2026-06-23 15:59:21 +03:30
8bf3cfe5e9 audit fanavaran logs and missing fields null fixed 2026-06-23 15:58:49 +03:30
SepehrYahyaee
523172da67 PDF generation packages 2026-06-23 15:56:51 +03:30
SepehrYahyaee
2fc6015213 PDF generation 2026-06-23 15:56:19 +03:30
f6ec7644ff Merge pull request 'update the fanavaran' (#151) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#151
2026-06-23 13:59:43 +03:30
bc70a8c930 update the fanavaran 2026-06-23 13:57:16 +03:30
SepehrYahyaee
cca3ed01a4 YARA-1045 2026-06-23 13:31:04 +03:30
4caa958175 Merge pull request 'main' (#150) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#150
2026-06-23 13:20:58 +03:30
7d10c00ce5 merge upstream 2026-06-23 13:17:44 +03:30
114e5e6604 fanavaran added 2 apis for get payload and submit manually 2026-06-23 13:17:30 +03:30
f00cb226a7 Merge pull request 'Fixed workflow step' (#149) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#149
2026-06-23 11:04:03 +03:30
SepehrYahyaee
e2b879d943 Fixed workflow step 2026-06-23 11:03:06 +03:30
d84bd24682 Merge pull request 'FAKE SMS + SEARCH APIs' (#148) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#148
2026-06-23 10:28:01 +03:30
SepehrYahyaee
ed2b6948cf FAKE SMS 2026-06-23 10:26:51 +03:30
SepehrYahyaee
c6b417ced7 Fix searching on GET APIs 2026-06-23 10:22:46 +03:30
16d3c54613 Merge pull request 'Fix v3 mirror flow' (#147) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#147
2026-06-22 17:31:32 +03:30
SepehrYahyaee
db569db9d1 Fix v3 mirror flow 2026-06-22 17:30:58 +03:30
83e82ea68e Merge pull request 'Added v3 of field-expert' (#146) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#146
2026-06-22 13:06:21 +03:30
SepehrYahyaee
8f29bb564c Added v3 of field-expert 2026-06-22 13:05:11 +03:30
89e715b0c9 Merge pull request 'update the car name' (#145) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#145
2026-06-21 17:45:51 +03:30
44f7ce5b54 update the car name 2026-06-21 17:44:18 +03:30
a2396da9e4 Merge pull request 'main' (#144) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#144
2026-06-21 17:13:11 +03:30
64f6245e06 merge upstream 2026-06-21 17:12:43 +03:30
df79a4d307 upserting the mongo error 2026-06-21 17:11:50 +03:30
65c30a6cba Merge pull request 'inquiry refresh service + fanavaran client config' (#143) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#143
2026-06-21 16:23:24 +03:30
0dd6c8ff78 inquiry refresh service + fanavaran client config 2026-06-21 16:20:03 +03:30
0442d04f20 Merge pull request 'Fixed smsApiKey index error and err handling in ESG' (#142) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#142
2026-06-21 12:19:15 +03:30
SepehrYahyaee
d0e7694374 Fixed smsApiKey index error and err handling in ESG 2026-06-21 12:18:08 +03:30
570fa865de Merge pull request 'Fix expert codes' (#141) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#141
2026-06-20 16:36:41 +03:30
SepehrYahyaee
8741d2ba82 Fix expert codes 2026-06-20 16:30:57 +03:30
7b53c98791 Merge pull request 'Fixed mock data' (#140) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#140
2026-06-20 15:49:27 +03:30
SepehrYahyaee
59eddb8e0e Fixed mock data 2026-06-20 15:48:39 +03:30
4e20fc5c96 Merge pull request 'YARA-1034, YARA-1035' (#139) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#139
2026-06-20 14:52:23 +03:30
SepehrYahyaee
4fabed77e5 YARA-1035 2026-06-20 14:51:01 +03:30
SepehrYahyaee
2e4b10455b YARA-1034 2026-06-20 14:30:29 +03:30
1bbf0de960 Merge pull request 'Added common utils' (#138) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#138
2026-06-20 12:05:15 +03:30
SepehrYahyaee
15fcb011aa Added common utils 2026-06-20 12:04:51 +03:30
2c52c14e03 Merge pull request 'Fixed mismatched userId on field expert for claim' (#137) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#137
2026-06-20 11:55:34 +03:30
SepehrYahyaee
5a89a0ff16 Fixed mismatched userId on field expert for claim 2026-06-20 11:53:18 +03:30
d355771518 Merge pull request 'ServeRoot fixing on WORKDIR' (#136) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#136
2026-06-19 15:06:59 +03:30
f4301428c7 ServeRoot fixing on WORKDIR 2026-06-19 15:05:35 +03:30
e3406f7645 Merge pull request 'Inquiry fix' (#135) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#135
2026-06-19 14:26:42 +03:30
4d6183fa24 Inquiry fix 2026-06-19 14:24:57 +03:30
ce4945ebb8 Merge pull request 'Error handling on empty damaged parts' (#134) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#134
2026-06-19 13:41:21 +03:30
5cada3c6c8 Error handling on empty damaged parts 2026-06-19 13:40:38 +03:30
761f0cb679 Merge pull request 'Added field expert support for insurer' (#133) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#133
2026-06-19 10:53:08 +03:30
3c61e4397a Added field expert support for insurer 2026-06-19 10:52:47 +03:30
fae7e9b13b Merge pull request 'Fixed users not being able to view their files' (#132) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#132
2026-06-19 09:51:16 +03:30
3c863bb90c Fixed users not being able to view their files 2026-06-19 09:50:42 +03:30
0c5756d325 Merge pull request 'Fixed GET users' (#131) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#131
2026-06-18 18:28:57 +03:30
1670c9d145 Merge upstream/main into main
Resolve conflicts while keeping field-expert lock/view fixes and user
party-access query improvements from the fork.
2026-06-18 18:27:01 +03:30
92e05d2a49 Fix async error 2026-06-18 18:22:15 +03:30
dcc3ee71de Fixed GET users 2026-06-18 18:15:20 +03:30
fa188862e5 Fixed Lock for Field expert + user view of files 2026-06-18 13:32:40 +03:30
d8f7766f10 Fixed Lock for Field expert + user view of files 2026-06-18 13:31:56 +03:30
084d0e1360 Merge pull request 'main' (#129) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#129
2026-06-17 17:00:08 +03:30
0111f3acd2 merge upstream 2026-06-17 16:59:36 +03:30
1ef17ce337 fanavaran parsian done 2026-06-17 16:57:21 +03:30
SepehrYahyaee
6df8044c5a Added new script 2026-06-17 16:39:59 +03:30
SepehrYahyaee
bc5be99b59 Fixed registrar and field expert 2026-06-17 16:39:30 +03:30
SepehrYahyaee
a4eb98258b New inquiries for parsian 2026-06-17 14:32:25 +03:30
SepehrYahyaee
ad35d35065 Fixed repetetive emails closing mongo connection 2026-06-17 12:37:18 +03:30
SepehrYahyaee
4bd88ff0dd Mirrored previous lookups for accident-ways 2026-06-16 11:31:41 +03:30
SepehrYahyaee
b008eda11b Fixed error in inquiry 2026-06-15 16:24:49 +03:30
1680fdc5b4 Added registrar + fixed conflicts
Reviewed-on: Yara724/api#124
2026-06-15 16:12:45 +03:30
SepehrYahyaee
921f9719c7 Merge upstream/main - resolve conflicts
- auth.module.ts: keep HashService (upstream accidentally removed it)
- payload.types.ts: merge both - add upstream's clientId field
- claim-request-management.module.ts: keep RegistrarClaimMirrorController
- expert-initiated-blame.mirror.controller.ts: keep our clean version

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 16:11:39 +03:30
SepehrYahyaee
a7849f915a Added registrar mirrored 2026-06-15 15:58:31 +03:30
SepehrYahyaee
19dc2a76f2 Added expert field mirror flow 2026-06-15 11:27:25 +03:30
SepehrYahyaee
41f81a2f76 Added expert field mirror flow 2026-06-15 11:24:41 +03:30
048398d653 merge upstream 2026-06-13 21:45:20 +03:30
SepehrYahyaee
79905345e5 Fix car-damage links 2026-06-13 17:43:33 +03:30
SepehrYahyaee
0ed7cd7012 Fix car-damage links 2026-06-13 17:43:00 +03:30
1e7b0d7d06 Merge pull request 'Fixing link for some documents' (#120) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#120
2026-06-13 17:25:25 +03:30
SepehrYahyaee
6426233350 Fix links 2026-06-13 17:24:25 +03:30
3e5e9852ad merge upstream 2026-06-13 15:52:20 +03:30
SepehrYahyaee
3d6b9e130c Fix invite second party link 2026-06-13 15:51:38 +03:30
SepehrYahyaee
ec07d42ced Fix invite second party link 2026-06-13 15:49:07 +03:30
407f58f5ee Merge pull request 'Added USER_BASE_PATH env' (#118) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#118
2026-06-13 15:30:21 +03:30
SepehrYahyaee
c8274d8435 Added USER_BASE_PATH env 2026-06-13 15:29:33 +03:30
f0b24dcd26 Merge pull request 'fixed mock data' (#117) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#117
2026-06-13 14:07:40 +03:30
SepehrYahyaee
5414d9717e Fix 2026-06-13 14:07:07 +03:30
SepehrYahyaee
e413991e7c Fixed mock data 2026-06-13 14:05:27 +03:30
b144458943 Merge pull request 'Added API for externalAPI, added env for clients' (#116) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#116
2026-06-13 11:27:13 +03:30
218 changed files with 28324 additions and 2281 deletions

View File

@@ -3,12 +3,17 @@
# --------------------------------------------- # ---------------------------------------------
NODE_ENV = NODE_ENV =
PORT = PORT =
CLIENT_ID = CLIENT_ID =
CLIENT_NAME = CLIENT_NAME =
FANAVARAN_CLIENT=parsian
INSURANCE_CORP_ID='شرکت بيمه پارسيان(بيمه گر)'
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=false
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN=53000000
# --------------------------------------------- # ---------------------------------------------
# 🌐 Application URLs # 🌐 Application URLs
# --------------------------------------------- # ---------------------------------------------
URL = URL =
USER_BASE_PATH =
BASE_URL_DEV = BASE_URL_DEV =
# --------------------------------------------- # ---------------------------------------------
@@ -28,11 +33,14 @@ MONGO_DB_NAME =
MONGO_OPTIONS = MONGO_OPTIONS =
MONGO_TLS = MONGO_TLS =
MONGO_TLS_ALLOW_INVALID_CERTS = MONGO_TLS_ALLOW_INVALID_CERTS =
MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB_NAME}?${MONGO_OPTIONS}'
# --------------------------------------------- # ---------------------------------------------
# 🔐 Authentication / Security # 🔐 Authentication / Security
# --------------------------------------------- # ---------------------------------------------
JWT_SECRET = JWT_SECRET =
JWT_EXPIRY =
CAPTCHA_ENABLED = true
# --------------------------------------------- # ---------------------------------------------
# 🧩 SanHub Microservice # 🧩 SanHub Microservice
@@ -57,6 +65,24 @@ SMS_PROVIDER =
SMS_API_KEY = SMS_API_KEY =
AUTH_SMS_TEMPLATE = AUTH_SMS_TEMPLATE =
EXP_OTP_TIME = EXP_OTP_TIME =
FAKE_OTP =
# ---------------------------------------------
# 🌐 Proxy Configuration (Local Development Only)
# ---------------------------------------------
# NOTE: These proxy settings are for local development only.
# When deploying to the server, comment out or remove these lines
# as the server IP is already whitelisted by Fanavaran.
# SOCKS_PROXY_HOST = localhost
# SOCKS_PROXY_PORT = 6565
# ---------------------------------------------
# 🏢 Fanavaran Insurance Corp
# ---------------------------------------------
# Caption from Fanavaran insurance-corp lookup used to resolve InsuranceCorpId
# for damage-case payloads. Must match a Caption in the insurance-corp code-list.
# Example: "شرکت بيمه تجارت نو"
INSURANCE_CORP_ID =
# --------------------------------------------- # ---------------------------------------------
# ⚙️ Application Features / Flags # ⚙️ Application Features / Flags

142
.woodpecker.yml Normal file
View File

@@ -0,0 +1,142 @@
# yara724/api — development deployment (Deploy-Develop)
# Manual tasks: see ci-cd/TASK.md
# Requires: repo marked Trusted in Woodpecker (host volume mounts)
when:
- event: push
branch: main
- event: manual
skip_clone: true
variables:
- &host_workspace /data/1-deploy/gitea/yara724/api
- &workspace_volume /data/1-deploy/gitea/yara724/api:/workspace
- &host_ssh /home/talieh/.ssh:/root/.ssh:ro
- &pipeline_env
WORKSPACE: *host_workspace
PROJECT_NAME: Yara724 API
ENVIRONMENT: Development
APPLICATION_URL: https://y724-user.ittalie.ir/api
GIT_COMMIT_URL: https://git.ittalie.com/Yara724/api/commit/
GIT_BRANCH: main
COMPOSE_FILE: docker-compose.yml
SUCCESS_COLOR: "#36a64f"
FAILURE_COLOR: "#dc3545"
steps:
pull:
image: docker.arvancloud.ir/alpine/git:latest
environment:
<<: *pipeline_env
GIT_SSH_COMMAND: ssh -o UserKnownHostsFile=/tmp/known_hosts -o StrictHostKeyChecking=yes
volumes:
- *workspace_volume
- *host_ssh
commands:
- git config --global --add safe.directory /workspace
- mkdir -p /tmp && ssh-keyscan -H git.ittalie.com >> /tmp/known_hosts
- cd /workspace
- git pull origin main
- date +%s > /workspace/.wp-deploy-start
deploy:
image: docker.arvancloud.ir/docker:24-cli
environment:
<<: *pipeline_env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- *workspace_volume
commands:
- cd /workspace
- docker compose -f docker-compose.yml up -d --build
notify-success:
image: docker.arvancloud.ir/alpine:3.20
environment:
<<: *pipeline_env
ROCKETCHAT_WEBHOOK:
from_secret: rocketchat_webhook
volumes:
- *workspace_volume
when:
- status: success
commands:
- apk add --no-cache curl jq git > /dev/null
- |
set -euo pipefail
git config --global --add safe.directory /workspace
cd /workspace
COMMIT_HASH="$(git rev-parse --short HEAD)"
DEPLOY_START="$(cat /workspace/.wp-deploy-start)"
DEPLOY_END="$(date +%s)"
DEPLOY_DURATION="$((DEPLOY_END - DEPLOY_START))"
COMMIT_1="$(git log -1 --pretty=format:'%s')"
COMMIT_2="$(git log -2 --pretty=format:'%s' | tail -n1)"
COMMIT_3="$(git log -3 --pretty=format:'%s' | tail -n1)"
TITLE="✅ $PROJECT_NAME - $ENVIRONMENT ✅"
TEXT="🌐 **URL**: $APPLICATION_URL
🔖 **Commit Hash**: [$COMMIT_HASH]($GIT_COMMIT_URL$COMMIT_HASH)
📝 **Recent Changes**:
• $COMMIT_1
• $COMMIT_2
• $COMMIT_3
⏱️ **Deployment Duration**: $${DEPLOY_DURATION}s"
payload="$(jq -n \
--arg title "$TITLE" \
--arg text "$TEXT" \
--arg color "$SUCCESS_COLOR" \
'{text: $title, attachments: [{text: $text, color: $color}]}')"
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null
rm -f /workspace/.wp-deploy-start
notify-failure:
image: docker.arvancloud.ir/alpine:3.20
environment:
<<: *pipeline_env
ROCKETCHAT_WEBHOOK:
from_secret: rocketchat_webhook
volumes:
- *workspace_volume
when:
- status: failure
commands:
- apk add --no-cache curl jq git > /dev/null
- |
set -euo pipefail
git config --global --add safe.directory /workspace
cd /workspace
COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
TITLE="💥 $PROJECT_NAME - $ENVIRONMENT 💥"
TEXT="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌐 **Application URL**
$APPLICATION_URL
🔖 **Commit**
\`$COMMIT_HASH\`
⚠️ **Pipeline failed** — check Woodpecker for the failing step.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
payload="$(jq -n \
--arg title "$TITLE" \
--arg text "$TEXT" \
--arg color "$FAILURE_COLOR" \
'{text: $title, attachments: [{text: $text, color: $color}]}')"
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null || true
rm -f /workspace/.wp-deploy-start

View File

@@ -1 +1,15 @@
# api # api
Current JWT payload:
```json
{
"username": "saman_insurer@gmail.com",
"sub": "6a144979799f3c63aa63f67c",
"fullName": "بیمه گر سامان",
"role": "company",
"userType": "UserType",
"clientKey": "67f0fd0e53868dc1ff8a2738",
"iat": 1781339791,
"exp": 1781343391
}
```

BIN
assets/Vazirmatn-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

742
docs/external-api-curls.md Normal file
View File

@@ -0,0 +1,742 @@
# External API Curl Guide
This file documents outbound HTTP calls made by the app or maintenance scripts.
Client credentials that are hardcoded in the codebase are filled in below. Keep
placeholders only for runtime data such as national codes, plate values, tokens
returned by login calls, and payload files.
## Common Notes
- Internal app URLs in Swagger, localhost examples, and file download URLs are not listed.
- Package/build-time network calls such as npm registry, Sonar, and Docker setup are not runtime app requests.
- `firstValueFrom(this.httpService...)`, `lastValueFrom(this.httpService...)`, and `fetch(...)` call sites were checked.
- Several integrations cache bearer tokens in memory for about 55 minutes.
- Some Fanavaran credentials and the Map.ir API key are hardcoded in source. Treat them as sensitive and consider moving/rotating them.
## Fanavaran API Manager
Host:
```sh
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
```
Used by:
- `src/fanavaran/fanavaran-lookup.service.ts`
- `src/fanavaran/fanavaran-lookup.config.ts`
- `src/claim-request-management/claim-request-management.service.ts`
### Sequence
1. Get `appToken`.
2. Login with `appToken` to get `authenticationToken`.
3. Call lookup, policy inquiry, or submit endpoint with `authenticationToken`, `CorpId`, `ContractId`, and `Location`.
### Auth Script
Use this when you only need fresh Fanavaran tokens and do not want to copy the
curl commands manually:
```sh
scripts/fanavaran-auth.sh tejaratno
scripts/fanavaran-auth.sh parsian
```
The script stores raw responses and reusable variables under
`files/fanavaran-auth/<client>/`. For example:
```sh
source files/fanavaran-auth/tejaratno/tokens.env
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/accident-causes" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
```
### Tenant Credentials
The app supports these Fanavaran client profiles:
| Client | appname | secret | userName | password | CorpId | ContractId | Location |
| --- | --- | --- | --- | --- | --- | --- | --- |
| tejaratno | `fanhab` | `5Fa@N#A2B` | `fanhabUser` | `Fan#@2U$3er` | `3539` | `263` | `100` |
| parsian | `ParsianService` | `P@r30@n$erv!ce` | `ParsianServiceUser` | `P@r30@n123` | `543` | `28` | `210050` |
Set the client variables before running. These values come from
`src/core/config/fanavaran-client.config.ts` and match
`src/claim-request-management/claim-request-management.service.ts`.
Tejaratno:
```sh
FANAVARAN_CLIENT="tejaratno"
APP_NAME='fanhab'
APP_SECRET='5Fa@N#A2B'
FANAVARAN_USERNAME='fanhabUser'
FANAVARAN_PASSWORD='Fan#@2U$3er'
CORP_ID='3539'
CONTRACT_ID='263'
LOCATION='100'
```
Parsian:
```sh
FANAVARAN_CLIENT="parsian"
APP_NAME='ParsianService'
APP_SECRET='P@r30@n$erv!ce'
FANAVARAN_USERNAME='ParsianServiceUser'
FANAVARAN_PASSWORD='P@r30@n123'
CORP_ID='543'
CONTRACT_ID='28'
LOCATION='210050'
```
### 1. Get App Token
The app sends an empty string body, deletes `Content-Type`, and keeps
`Content-Length: 0`.
```sh
curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
-H "appname: $APP_NAME" \
-H "secret: $APP_SECRET" \
-H "Content-Length: 0"
```
The token is returned in a response header named `appToken` or `apptoken`.
```sh
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
-H "appname: $APP_NAME" \
-H "secret: $APP_SECRET" \
-H "Content-Length: 0"
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
printf 'APP_TOKEN=%s\n' "$APP_TOKEN"
```
### 2. Login
Use the `APP_TOKEN` returned by the immediately previous GetAppToken request.
Do not reuse an old app token pasted from logs or another machine; the app does
not do that.
```sh
curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
-H "appToken: $APP_TOKEN" \
-H "userName: $FANAVARAN_USERNAME" \
-H "password: $FANAVARAN_PASSWORD" \
-H "Content-Length: 0"
```
The token is returned in a response header or body field named `authenticationToken`, `authenticationtoken`, or `authentication_token`.
```sh
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
-H "appToken: $APP_TOKEN" \
-H "userName: $FANAVARAN_USERNAME" \
-H "password: $FANAVARAN_PASSWORD" \
-H "Content-Length: 0"
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
if [ -z "$AUTHENTICATION_TOKEN" ]; then
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
fi
printf 'AUTHENTICATION_TOKEN=%s\n' "$AUTHENTICATION_TOKEN"
```
If login returns `نام کاربر یا رمز عبور صحیح نیست` for `tejaratno`, first check
that `APP_TOKEN` was generated with `appname: fanhab` and `secret: 5Fa@N#A2B` in
the same sequence. The runtime code calls `GetAppToken` first, then passes that
fresh header value to `Login`; it does not use a static value such as
`182197f6-7b41-47b4-9b18-5bfcbc027f2e`.
### Ready-To-Run Auth Sequences
Tejaratno:
```sh
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
FANAVARAN_CLIENT="tejaratno"
APP_NAME='fanhab'
APP_SECRET='5Fa@N#A2B'
FANAVARAN_USERNAME='fanhabUser'
FANAVARAN_PASSWORD='Fan#@2U$3er'
CORP_ID='3539'
CONTRACT_ID='263'
LOCATION='100'
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
-H "appname: $APP_NAME" \
-H "secret: $APP_SECRET" \
-H "Content-Length: 0"
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
-H "appToken: $APP_TOKEN" \
-H "userName: $FANAVARAN_USERNAME" \
-H "password: $FANAVARAN_PASSWORD" \
-H "Content-Length: 0"
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
if [ -z "$AUTHENTICATION_TOKEN" ]; then
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
fi
printf 'APP_TOKEN=%s\nAUTHENTICATION_TOKEN=%s\n' "$APP_TOKEN" "$AUTHENTICATION_TOKEN"
```
Parsian:
```sh
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
FANAVARAN_CLIENT="parsian"
APP_NAME='ParsianService'
APP_SECRET='P@r30@n$erv!ce'
FANAVARAN_USERNAME='ParsianServiceUser'
FANAVARAN_PASSWORD='P@r30@n123'
CORP_ID='543'
CONTRACT_ID='28'
LOCATION='210050'
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
-H "appname: $APP_NAME" \
-H "secret: $APP_SECRET" \
-H "Content-Length: 0"
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
-H "appToken: $APP_TOKEN" \
-H "userName: $FANAVARAN_USERNAME" \
-H "password: $FANAVARAN_PASSWORD" \
-H "Content-Length: 0"
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
if [ -z "$AUTHENTICATION_TOKEN" ]; then
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
fi
printf 'APP_TOKEN=%s\nAUTHENTICATION_TOKEN=%s\n' "$APP_TOKEN" "$AUTHENTICATION_TOKEN"
```
### 3A. Lookup Endpoints
These are fetched on demand and cached under `files/fanavaran-lookups/<client>/`.
```sh
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/accident-causes" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/accident-report-type" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/vehicle-use-types" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/dmg-pay-method" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/driving-licence-types" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/accident-culprit-type" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/vehicle-kinds" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
curl -X GET "$FANAVARAN_BIME_URL/car/vehicles/inquiry-by-vin?vin=IRNKAEK4150012345" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
```
### 3B. Policy Inquiry By National Code
Used before Fanavaran claim submit to resolve a `PolicyId` when possible.
```sh
NATIONAL_CODE="<insurer national code>"
curl -X GET "$FANAVARAN_BIME_URL/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=$NATIONAL_CODE" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json"
```
### 3C. Third-Party Car Financial Claim Submit
`fanavaranData` is built internally from a claim case/request. The shape is large; capture an app log or preview output and save it as JSON before replaying.
```sh
curl -X POST "$FANAVARAN_BIME_URL/car/third-party-car-financial-claims" \
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
-H "CorpId: $CORP_ID" \
-H "ContractId: $CONTRACT_ID" \
-H "Location: $LOCATION" \
-H "Content-Type: application/json" \
--data @fanavaran-claim-submit.json
```
## Tejarat Inquiry Provider
Default base URL:
```sh
TEJARAT_INQUIRY_BASE_URL="${TEJARAT_INQUIRY_BASE_URL:-http://82.99.202.245:3027}"
TEJARAT_INQUIRY_EMAIL='xxx@example.com'
TEJARAT_INQUIRY_PASSWORD='123321'
```
Used by `src/sand-hub/sand-hub.service.ts` for third-party plate and car-body plate inquiries when ESG is not selected.
### Sequence
1. Login to `/user/login`.
2. Use returned `accessToken` as `Authorization: Bearer ...`.
3. Call inquiry endpoint.
### Login
```sh
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/user/login" \
-H "Accept: */*" \
-H "Content-Type: application/json" \
--data '{
"email": "'"$TEJARAT_INQUIRY_EMAIL"'",
"password": "'"$TEJARAT_INQUIRY_PASSWORD"'"
}'
```
```sh
TEJARAT_ACCESS_TOKEN="<response accessToken>"
```
### Third-Party Plate / Policy Block Inquiry
```sh
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/block-inquiry-tejarat" \
-H "Authorization: Bearer $TEJARAT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"leftTwoDigits": "12",
"serialLetter": "ب",
"threeDigits": "345",
"rightTwoDigits": "67",
"nationalCode": "0012345678"
}'
```
### Car-Body Plate Inquiry
```sh
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/block-inquiry-tejarat/badane" \
-H "Authorization: Bearer $TEJARAT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"part1": 12,
"part2": "ب",
"part3": 345,
"part4": 67,
"nationalCode": "0012345678"
}'
```
## ESG Inquiry Provider
Default/fallback base URL in some call sites:
```sh
ESG_URL="${ESG_URL:-http://192.168.20.22:8085}"
```
Used by `src/sand-hub/sand-hub.service.ts` for selected tenants, for example when `CLIENT_ID=8`.
### Sequence
1. Login to `/auth/login`.
2. Use returned `accessToken` as `Authorization: Bearer ...`.
3. Call the inquiry endpoint.
### Login
```sh
curl -X POST "$ESG_URL/auth/login" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data '{
"username": "'"$ESG_USERNAME"'",
"password": "'"$ESG_PASSWORD"'"
}'
```
```sh
ESG_ACCESS_TOKEN="<response accessToken>"
```
### Policy By Plate
```sh
curl -X POST "$ESG_URL/inquiry/policyByPlate" \
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"nationalCode": "0012345678",
"plk1": "12",
"plk2": "ب",
"plk3": "345",
"plksrl": "67"
}'
```
### Person Inquiry
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
```sh
curl -X POST "$ESG_URL/inquiry/person" \
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"nationalCode": "0012345678",
"birthDate": "1378-11-24",
"dateHasPostfix": 0
}'
```
### Sheba Validation
```sh
curl -X POST "$ESG_URL/inquiry/sheba" \
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"accountOwnerType": "1",
"nationalCode": "0012345678",
"legalId": "",
"sheba": "IR000000000000000000000000"
}'
```
## SandHub Provider
Used by `src/sand-hub/sand-hub.service.ts` for legacy inquiry flows.
Environment:
```sh
SANHUB_BASE_URL='http://82.99.202.245:3027'
SANHUB_URL_LOGIN='http://82.99.202.245:3027/user/login'
SANHUB_USERNAME='default@admin.com'
SANHUB_PASSWORD='123321'
```
### Sequence
1. Login through `SANHUB_URL_LOGIN`.
2. Use returned `accessToken` as `Authorization: Bearer ...`.
3. Call the required endpoint under `SANHUB_BASE_URL`.
### Login
```sh
curl -X POST "$SANHUB_URL_LOGIN" \
-H "Content-Type: application/json" \
--data '{
"email": "'"$SANHUB_USERNAME"'",
"password": "'"$SANHUB_PASSWORD"'"
}'
```
```sh
SANHUB_ACCESS_TOKEN="<response accessToken>"
```
### Third-Party Plate / Policy Block Inquiry
```sh
curl -X POST "$SANHUB_BASE_URL/block-inquiry-tejarat" \
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"leftTwoDigits": "12",
"serialLetter": "ب",
"threeDigits": "345",
"rightTwoDigits": "67",
"nationalCode": "0012345678"
}'
```
### Personal Inquiry
The app converts Jalali birth dates to Gregorian before sending to SandHub.
```sh
curl -X POST "$SANHUB_BASE_URL/personal-inquiry/tejarat-no" \
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"nationalCode": "0012345678",
"birthdate": "1999-02-13"
}'
```
### Driving License Check
```sh
curl -X POST "$SANHUB_BASE_URL/driver-license-check" \
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"driverLicenseNumber": "1234567890",
"nationalCode": "0012345678"
}'
```
### Car Ownership
```sh
curl -X POST "$SANHUB_BASE_URL/ownership" \
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"Plk1": "12",
"Plk2": "ب",
"Plk3": "345",
"plkSrl": "67",
"nationalCode": "0012345678"
}'
```
### Sheba Validation
```sh
curl -X POST "$SANHUB_BASE_URL/sheba/sheba-tejaratno" \
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data '{
"AccountOwnerType": "1",
"NationalId": "0012345678",
"ShebaId": "IR000000000000000000000000"
}'
```
## Map.ir Reverse Geocoding
Used by `src/claim-request-management/claim-request-management.service.ts`.
```sh
MAP_IR_API_KEY='eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg'
LAT="35.6892"
LON="51.3890"
curl -X GET "https://map.ir/fast-reverse?lat=$LAT&lon=$LON" \
-H "accept: application/json" \
-H "x-api-key: $MAP_IR_API_KEY"
```
## SMS Providers
### Kavenegar
Used by `src/sms-orchestration/provider/kavenegar.service.ts`.
```sh
SMS_API_KEY='75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E'
KAVENEGAR_BASE_URL="https://api.kavenegar.com/v1/$SMS_API_KEY"
```
Send SMS:
```sh
curl -X POST -G "$KAVENEGAR_BASE_URL/sms/send.json" \
--data-urlencode "receptor=09120000000" \
--data-urlencode "message=Hello" \
--data-urlencode "sender=<optional sender>"
```
Verify lookup:
```sh
curl -G "$KAVENEGAR_BASE_URL/verify/lookup.json" \
--data-urlencode "receptor=09120000000" \
--data-urlencode "token=123456" \
--data-urlencode "template=<template>" \
--data-urlencode "token2=<optional token2>" \
--data-urlencode "token3=<optional token3>"
```
### Parsian SMS Gateway
Used by `src/sms-orchestration/provider/parsian-sms.gateway.ts`.
`PARSIAN_SMS_URL` is expected to already include the provider URL prefix and query key before receptor. The app appends `=<receptor>&Message=<encoded message>`.
```sh
PARSIAN_SMS_URL='https://apigateway.parsianinsurance.com/api/SendSMS?ReceiverNumbers'
PARSIAN_API_KEY='be988c9c-dbd6-494e-9c04-944ecc6426bf'
PARSIAN_BASIC_TOKEN='UGFyc2lhbkFQSTpQYXJzaWFuQHBpMjI='
RECEPTOR="09120000000"
MESSAGE="Hello"
curl -X GET "$PARSIAN_SMS_URL=$RECEPTOR&Message=$(printf %s "$MESSAGE" | jq -sRr @uri)" \
-H "Content-Type: application/json" \
-H "X-PACKAGE-API-KEY: $PARSIAN_API_KEY" \
-H "Authorization: Basic $PARSIAN_BASIC_TOKEN"
```
## Car Price Provider
Used by `src/expert-claim/expert-claim.service.ts` to fetch car prices from `CW_URL`.
```sh
CW_URL="<base URL ending with slash if required by provider>"
curl -X GET "${CW_URL}price?akharin"
curl -X GET "${CW_URL}price?hamrah"
```
## AI Service Calls Currently Disabled
`src/ai/ai.service.ts` contains configured URLs but the actual axios calls are commented out. If re-enabled, the sequence is:
1. `POST $AI_URL_V2/auth/login`
2. `GET $AI_URL_V2/auth/profile`
3. `POST $AI_URL_V2/services/car-damage/detector?version=ai-v7`
```sh
AI_URL_V2='https://ai-gw.ittalie.ir'
AI_USERNAME='yara@gmail.io'
AI_PASSWORD='123321'
curl -X POST "$AI_URL_V2/auth/login" \
-H "Content-Type: application/json" \
--data '{
"username": "'"$AI_USERNAME"'",
"password": "'"$AI_PASSWORD"'"
}'
AI_ACCESS_TOKEN="<response accessToken>"
curl -X GET "$AI_URL_V2/auth/profile" \
-H "Authorization: Bearer $AI_ACCESS_TOKEN"
GATEWAY_API_KEY="<profile apiKey.key>"
curl -X POST "$AI_URL_V2/services/car-damage/detector?version=ai-v7" \
-H "Authorization: Bearer $AI_ACCESS_TOKEN" \
-H "gateway-api-key: $GATEWAY_API_KEY" \
-F "images=@/path/to/car-image.jpg"
```
## Refresh Blame Inquiries Script
Used by `scripts/refresh-blame-inquiries.js`. This script does not login; it expects pre-provided bearer tokens:
- `TEJARAT_THIRD_PARTY_TOKEN` or `TEJARAT_TOKEN`
- `TEJARAT_CAR_BODY_TOKEN` or `TEJARAT_TOKEN`
- `TEJARAT_PERSON_TOKEN` or `TEJARAT_TOKEN`
Default URLs:
```sh
TEJARAT_THIRD_PARTY_URL="${TEJARAT_THIRD_PARTY_URL:-http://82.99.202.245:3027/block-inquiry-tejarat}"
TEJARAT_CAR_BODY_URL="${TEJARAT_CAR_BODY_URL:-http://82.99.202.245:3027/block-inquiry-tejarat/badane}"
TEJARAT_PERSON_URL="${TEJARAT_PERSON_URL:-http://82.99.202.245:3027/personal-inquiry/tejarat-no}"
```
Third-party inquiry:
```sh
curl -X POST "$TEJARAT_THIRD_PARTY_URL" \
-H "authorization: Bearer $TEJARAT_THIRD_PARTY_TOKEN" \
-H "content-type: application/json" \
-H "accept: application/json" \
--data '{
"leftTwoDigits": "12",
"serialLetter": "ب",
"threeDigits": "345",
"rightTwoDigits": "67",
"nationalCode": "0012345678"
}'
```
Car-body inquiry:
```sh
curl -X POST "$TEJARAT_CAR_BODY_URL" \
-H "authorization: Bearer $TEJARAT_CAR_BODY_TOKEN" \
-H "content-type: application/json" \
-H "accept: application/json" \
--data '{
"part1": 12,
"part2": "ب",
"part3": 345,
"part4": 67,
"nationalCode": "0012345678"
}'
```
Personal inquiry:
```sh
curl -X POST "$TEJARAT_PERSON_URL" \
-H "authorization: Bearer $TEJARAT_PERSON_TOKEN" \
-H "content-type: application/json" \
-H "accept: application/json" \
--data '{
"nationalCode": "0012345678",
"birthdate": "1999-02-13"
}'
```

View File

@@ -3,6 +3,13 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": [
{
"include": "../assets/fonts/**/*",
"outDir": "dist",
"watchAssets": true
}
]
} }
} }

325
package-lock.json generated
View File

@@ -23,8 +23,10 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"express": "^5.2.1", "express": "^5.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.6",
"joi": "^18.2.1", "joi": "^18.2.1",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"svg-captcha": "^1.4.0" "svg-captcha": "^1.4.0"
@@ -179,24 +181,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": { "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -204,22 +188,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -275,24 +243,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": { "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -300,22 +250,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/rxjs": { "node_modules/@angular-devkit/schematics/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -1075,9 +1009,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@hapi/tlds": { "node_modules/@hapi/tlds": {
"version": "1.1.6", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz",
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2905,24 +2839,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -2930,22 +2846,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@nestjs/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/rxjs": { "node_modules/@nestjs/schematics/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -3044,11 +2944,22 @@
} }
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
@@ -3492,6 +3403,15 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": { "node_modules/@swc/types": {
"version": "0.1.26", "version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
@@ -5219,7 +5139,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5475,6 +5394,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -5794,24 +5731,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chrome-trace-event": { "node_modules/chrome-trace-event": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -6331,6 +6250,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -6935,7 +6860,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@@ -7195,6 +7119,32 @@
} }
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fork-ts-checker-webpack-plugin": { "node_modules/fork-ts-checker-webpack-plugin": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz",
@@ -7254,16 +7204,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -7711,9 +7661,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -8810,9 +8760,9 @@
} }
}, },
"node_modules/joi": { "node_modules/joi": {
"version": "18.2.1", "version": "18.2.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz",
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@hapi/address": "^5.1.1", "@hapi/address": "^5.1.1",
@@ -8827,6 +8777,12 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -9032,6 +8988,25 @@
"integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==", "integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9914,6 +9889,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -10052,6 +10033,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -10178,6 +10173,14 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -10396,22 +10399,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -10547,6 +10534,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -11968,6 +11961,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",

View File

@@ -12,6 +12,7 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -38,10 +39,13 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"express": "^5.2.1", "express": "^5.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.6",
"joi": "^18.2.1", "joi": "^18.2.1",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socks-proxy-agent": "^8.0.4",
"svg-captcha": "^1.4.0" "svg-captcha": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
@@ -83,6 +87,9 @@
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],

View File

@@ -0,0 +1,185 @@
{
"clientCode": 8,
"branches": [
{
"code": "100100",
"name": "واحدصدورالکترونيکي",
"fullName": "واحدصدورالکترونيکي(100100)",
"city": "تهران",
"state": "تهران",
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "110011",
"name": "ستاد مرکزي",
"fullName": "ستاد مرکزي(110011)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "111130",
"name": "شعبه ويژه ميرداماد",
"fullName": "شعبه ويژه ميرداماد(111130)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "120021",
"name": "سرپرستي منطقه يک كشور",
"fullName": "سرپرستي منطقه يک كشور(120021)",
"city": "تهران",
"state": "تهران",
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
"phoneNumber": "0218259",
"isActive": true
},
{
"code": "130031",
"name": "سرپرستي منطقه مركزي كشور",
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
"city": "تهران",
"state": "تهران",
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
"phoneNumber": "03133328257",
"isActive": true
},
{
"code": "140041",
"name": "سرپرستي منطقه شمالغرب کشور",
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
"city": "تهران",
"state": "تهران",
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
"phoneNumber": "04133832289",
"isActive": true
},
{
"code": "150051",
"name": "سرپرستي منطقه جنوب كشور",
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
"city": "تهران",
"state": "تهران",
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
"phoneNumber": "01738315473",
"isActive": true
},
{
"code": "150053",
"name": "سرپرست منطقه جنوب شرقي کشور",
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
"city": "کرمان",
"state": "کرمان",
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
"phoneNumber": "03432718000",
"isActive": true
},
{
"code": "160061",
"name": "سرپرستي منطقه شرق كشور",
"fullName": "سرپرستي منطقه شرق كشور(160061)",
"city": "تهران",
"state": "تهران",
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
"phoneNumber": "05137659005",
"isActive": true
},
{
"code": "170071",
"name": "سرپرستي منطقه غرب كشور",
"fullName": "سرپرستي منطقه غرب كشور(170071)",
"city": "تهران",
"state": "تهران",
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
"phoneNumber": "08338431017",
"isActive": true
},
{
"code": "180081",
"name": "سرپرستي منطقه شمال شرق کشور",
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
"city": "ساري",
"state": "مازندران",
"address": "ساري، شعبه ساري",
"phoneNumber": "01133207241",
"isActive": true
},
{
"code": "180083",
"name": "سرپرست منطقه شمال کشوري",
"fullName": "سرپرست منطقه شمال کشوري(180083)",
"city": "رشت",
"state": "گيلان",
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
"phoneNumber": "01333512135",
"isActive": true
},
{
"code": "190092",
"name": "سرپرستي جنوب غربي کشور",
"fullName": "سرپرستي جنوب غربي کشور(190092)",
"city": "اهواز",
"state": "خوزستان",
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
"phoneNumber": "06133743877",
"isActive": true
},
{
"code": "210040",
"name": "شعبه شرق تهران",
"fullName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
"phoneNumber": "77393783-4",
"isActive": true
},
{
"code": "210050",
"name": "شعبه غرب تهران",
"fullName": "شعبه غرب تهران(210050)",
"city": "تهران",
"state": "تهران",
"address": "تهران ـخيابان آزادي ( محله تيموري )، نبش خيابان شهيد داود حبيب زادگان پلاک 2 - 1458887853",
"phoneNumber": "66021968",
"isActive": true
},
{
"code": "210110",
"name": "شعبه پونک",
"fullName": "شعبه پونک(210110)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان ميرزا بابايي، نبش خيابان سردارجنگل، پارك سوارپونك",
"phoneNumber": "44452270",
"isActive": true
},
{
"code": "210120",
"name": "شعبه والفجر",
"fullName": "شعبه والفجر(210120)",
"city": "تهران",
"state": "تهران",
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
"phoneNumber": "86051332",
"isActive": true
},
{
"code": "210150",
"name": "شعبه شمال شرق تهران",
"fullName": "شعبه شمال شرق تهران(210150)",
"city": "تهران",
"state": "تهران",
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
"phoneNumber": "26244319",
"isActive": true
}
]
}

View File

@@ -0,0 +1,153 @@
{
"clientCode": 8,
"fieldExperts": [
{
"nationalCode": "0013480261",
"firstName": "عليرضا",
"lastName": "خازني",
"branchCode": "210040",
"branchName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "0051967839",
"mobile": "09121354859",
"firstName": "حسين",
"lastName": "جعفري",
"branchCode": "210120",
"branchName": "شعبه والفجر",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "0056888082",
"mobile": "09122406750",
"firstName": "قاسم",
"lastName": "نصراللهي",
"branchCode": "210050",
"branchName": "شعبه غرب تهران(210050)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت ثالث مالي",
"expertCode": "4664"
},
{
"nationalCode": "0066868521",
"mobile": "09129344240",
"firstName": "عليرضا",
"lastName": "گودرزي پور",
"branchCode": "210050",
"branchName": "شعبه غرب تهران(210050)",
"title": "كارشناس ارزياب خسارت بدنه",
"expertCode": "4663"
},
{
"nationalCode": "0076988961",
"mobile": "09108357378",
"firstName": "مهدي",
"lastName": "روشن دل",
"branchCode": "210110",
"branchName": "شعبه پونک",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "0078209129",
"mobile": "09126038117",
"firstName": "مهدي",
"lastName": "شاملوفرد",
"branchCode": "210050",
"branchName": "شعبه غرب تهران(210050)",
"title": "كارشناس ارزياب خسارت ثالث مالي",
"expertCode": "4666"
},
{
"nationalCode": "0083730397",
"mobile": "09125759960",
"firstName": "مجيد",
"lastName": "کاظمي دولت سرا",
"branchCode": "210120",
"branchName": "شعبه والفجر",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "0084130938",
"mobile": "09392558640",
"firstName": "رسول",
"lastName": "کرکي",
"branchCode": "210050",
"branchName": "شعبه غرب تهران(210050)",
"title": "كارشناس ارزياب خسارت ثالث مالي",
"expertCode": "4662"
},
{
"nationalCode": "0440245151",
"mobile": "09130606183",
"firstName": "فرهاد",
"lastName": "ملکي مونقي",
"branchCode": "110011",
"branchName": "ستاد مرکزي",
"title": "كارشناس ارزياب خسارت ثالث مالي"
},
{
"nationalCode": "0493217789",
"mobile": "09126966943",
"firstName": "مصطفي",
"lastName": "محمدزاده قورقچي",
"branchCode": "210050",
"branchName": "شعبه غرب تهران(210050)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت ثالث مالي",
"expertCode": "4665"
},
{
"nationalCode": "0670358118",
"mobile": "09124421539",
"firstName": "مجيد",
"lastName": "اميري",
"branchCode": "210040",
"branchName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت ثالث مالي"
},
{
"nationalCode": "0759153981",
"firstName": "رضا",
"lastName": "صالحي زاده",
"branchCode": "210040",
"branchName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "1262982308",
"mobile": "09130121246",
"firstName": "روح الله",
"lastName": "سلمانيان مقدم نياسري",
"branchCode": "210120",
"branchName": "شعبه والفجر",
"city": "کاشان",
"state": "اصفهان",
"title": "كارشناس ارزياب خسارت بدنه"
},
{
"nationalCode": "3781847039",
"firstName": "اکبر",
"lastName": "ديني",
"branchCode": "210040",
"branchName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"title": "كارشناس ارزياب خسارت ثالث مالي"
}
]
}

143
scripts/fanavaran-auth.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
scripts/fanavaran-auth.sh <tejaratno|parsian>
Calls Fanavaran GetAppToken, then Login with the returned appToken.
Outputs the appToken and authenticationToken, and writes raw responses to:
files/fanavaran-auth/<client>/
Optional:
FANAVARAN_BASE_URL can override the default API Manager base URL.
USAGE
}
client="${1:-}"
if [[ -z "$client" || "$client" == "-h" || "$client" == "--help" ]]; then
usage
exit 0
fi
case "$client" in
tejaratno)
app_name='fanhab'
app_secret='5Fa@N#A2B'
fanavaran_username='fanhabUser'
fanavaran_password='Fan#@2U$3er'
corp_id='3539'
contract_id='263'
location='100'
;;
parsian)
app_name='ParsianService'
app_secret='P@r30@n$erv!ce'
fanavaran_username='ParsianServiceUser'
fanavaran_password='P@r30@n123'
corp_id='543'
contract_id='28'
location='210050'
;;
*)
printf 'Unknown Fanavaran client: %s\n\n' "$client" >&2
usage >&2
exit 1
;;
esac
base_url="${FANAVARAN_BASE_URL:-https://apimanager.iraneit.com/BimeApiManager/api}"
auth_dir="files/fanavaran-auth/$client"
mkdir -p "$auth_dir"
app_token_headers="$auth_dir/get-app-token.headers"
app_token_body="$auth_dir/get-app-token.body.json"
login_headers="$auth_dir/login.headers"
login_body="$auth_dir/login.body.json"
tokens_file="$auth_dir/tokens.env"
extract_header() {
local header_name="$1"
local header_file="$2"
awk -F': ' -v wanted="$header_name" '
tolower($1) == tolower(wanted) {
gsub(/\r/, "", $2)
print $2
exit
}
' "$header_file"
}
extract_authentication_token_from_body() {
local body_file="$1"
node -e '
const fs = require("fs");
const path = process.argv[1];
const body = fs.existsSync(path) ? fs.readFileSync(path, "utf8") : "";
try {
const json = JSON.parse(body || "{}");
console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || "");
} catch {
console.log("");
}
' "$body_file"
}
printf 'Fanavaran client: %s\n' "$client"
printf 'Base URL: %s\n\n' "$base_url"
printf '1. Calling GetAppToken...\n'
curl -sS -D "$app_token_headers" -o "$app_token_body" \
-X POST "$base_url/EITAuthentication/GetAppToken" \
-H "appname: $app_name" \
-H "secret: $app_secret" \
-H "Content-Length: 0"
app_token="$(extract_header "apptoken" "$app_token_headers")"
if [[ -z "$app_token" ]]; then
printf 'Failed to extract appToken from %s\n' "$app_token_headers" >&2
printf 'Response body is saved at %s\n' "$app_token_body" >&2
exit 1
fi
printf '2. Calling Login...\n'
curl -sS -D "$login_headers" -o "$login_body" \
-X POST "$base_url/EITAuthentication/Login" \
-H "appToken: $app_token" \
-H "userName: $fanavaran_username" \
-H "password: $fanavaran_password" \
-H "Content-Length: 0"
authentication_token="$(extract_header "authenticationtoken" "$login_headers")"
if [[ -z "$authentication_token" ]]; then
authentication_token="$(extract_authentication_token_from_body "$login_body")"
fi
if [[ -z "$authentication_token" ]]; then
printf 'Failed to extract authenticationToken from login response.\n' >&2
printf 'Headers: %s\n' "$login_headers" >&2
printf 'Body: %s\n' "$login_body" >&2
exit 1
fi
cat > "$tokens_file" <<TOKENS
FANAVARAN_CLIENT='$client'
FANAVARAN_BASE_URL='$base_url'
FANAVARAN_BIME_URL='$base_url/BimeApi/v2.0'
APP_TOKEN='$app_token'
AUTHENTICATION_TOKEN='$authentication_token'
CORP_ID='$corp_id'
CONTRACT_ID='$contract_id'
LOCATION='$location'
TOKENS
printf '\nDone.\n'
printf 'APP_TOKEN=%s\n' "$app_token"
printf 'AUTHENTICATION_TOKEN=%s\n' "$authentication_token"
printf 'CORP_ID=%s\n' "$corp_id"
printf 'CONTRACT_ID=%s\n' "$contract_id"
printf 'LOCATION=%s\n' "$location"
printf '\nSaved token variables: %s\n' "$tokens_file"
printf 'Saved raw GetAppToken response: %s, %s\n' "$app_token_headers" "$app_token_body"
printf 'Saved raw Login response: %s, %s\n' "$login_headers" "$login_body"

View File

@@ -0,0 +1,323 @@
/**
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
*
* Usage (before starting the app):
* npm run seed:parsian-tehran
*
* Optional env:
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import * as crypto from "node:crypto";
import mongoose, { Schema, Types } from "mongoose";
type BranchSeed = {
code: string;
name: string;
fullName?: string;
city: string;
state: string;
address: string;
phoneNumber?: string;
isActive?: boolean;
};
type FieldExpertSeed = {
nationalCode: string;
mobile?: string;
firstName: string;
lastName: string;
branchCode: string;
branchName?: string;
city?: string;
state?: string;
title?: string;
expertCode?: string;
};
function stripQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function stripInlineComment(value: string): string {
const hashIdx = value.indexOf(" #");
return hashIdx === -1 ? value : value.slice(0, hashIdx).trim();
}
function expandEnvValue(value: string, env: NodeJS.ProcessEnv): string {
return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => env[key] ?? "");
}
function loadEnvFile() {
const envPath = join(process.cwd(), ".env");
if (!existsSync(envPath)) return;
const raw: Record<string, string> = {};
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = stripInlineComment(trimmed.slice(idx + 1).trim());
raw[key] = value;
}
for (const [key, value] of Object.entries(raw)) {
if (process.env[key]) continue;
process.env[key] = stripQuotes(value);
}
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
for (let pass = 0; pass < 5; pass++) {
let changed = false;
for (const key of Object.keys(process.env)) {
const current = process.env[key];
if (!current || !current.includes("${")) continue;
const expanded = expandEnvValue(stripQuotes(current), process.env);
if (expanded !== current) {
process.env[key] = expanded;
changed = true;
}
}
if (!changed) break;
}
for (const key of Object.keys(process.env)) {
const value = process.env[key];
if (value) process.env[key] = stripQuotes(value);
}
}
function resolveMongoUri(): string {
const uri = process.env.MONGO_URI?.trim();
if (!uri) {
throw new Error("MONGO_URI is not set in .env");
}
if (!uri.startsWith("mongodb://") && !uri.startsWith("mongodb+srv://")) {
throw new Error(
`Invalid MONGO_URI after env expansion: "${uri.slice(0, 40)}..."`,
);
}
return uri;
}
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
const indexes = await collection.indexes();
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
if (emailIndex && !emailIndex.sparse) {
await collection.dropIndex(emailIndex.name);
console.log(`Dropped legacy non-sparse index: ${emailIndex.name}`);
}
await collection.createIndex({ email: 1 }, { unique: true, sparse: true });
await collection.createIndex(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
}
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString("hex");
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(`${salt}:${derivedKey.toString("hex")}`);
});
});
}
const ClientSchema = new Schema(
{
clientName: { type: Object, required: true },
clientCode: { type: Number, required: true },
useExpertMode: { type: String, required: true },
},
{ collection: "clients", versionKey: false },
);
const BranchSchema = new Schema(
{
clientKey: { type: Schema.Types.ObjectId, required: true, index: true },
name: { type: String, required: true },
code: { type: String, required: true },
city: { type: String, required: true },
state: { type: String, required: true },
address: { type: String, required: true },
phoneNumber: { type: String },
isActive: { type: Boolean, default: true },
},
{ collection: "branches", versionKey: false, timestamps: true },
);
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
const FieldExpertSchema = new Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, unique: true, sparse: true },
username: { type: String },
nationalCode: { type: String, index: true, sparse: true },
clientKey: { type: Schema.Types.ObjectId, index: true },
branchId: { type: Schema.Types.ObjectId, index: true },
password: { type: String, required: true },
mobile: { type: String },
phone: { type: String },
role: { type: String, default: "field_expert" },
otp: { type: String, default: "" },
expertCode: { type: String, required: false },
},
{ collection: "field-expert", versionKey: false, timestamps: true },
);
FieldExpertSchema.index(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
async function main() {
loadEnvFile();
const mongoUri = resolveMongoUri();
const dataDir = join(process.cwd(), "scripts/data/parsian-tehran");
const branchesFile = JSON.parse(
readFileSync(join(dataDir, "branches.json"), "utf8"),
) as { clientCode: number; branches: BranchSeed[] };
const expertsFile = JSON.parse(
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
const defaultPassword =
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
const hashedPassword = await hashPassword(defaultPassword);
await mongoose.connect(mongoUri, {
tls: process.env.MONGO_TLS === "true",
tlsAllowInvalidCertificates:
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
});
const Client = mongoose.model("ClientSeedClient", ClientSchema);
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
await ensureFieldExpertIndexes(FieldExpert.collection);
const client = await Client.findOne({
clientCode: branchesFile.clientCode,
}).lean();
if (!client?._id) {
throw new Error(
`Client with clientCode=${branchesFile.clientCode} not found in database`,
);
}
const clientKey = new Types.ObjectId(String(client._id));
const branchIdByCode = new Map<string, Types.ObjectId>();
let branchesCreated = 0;
let branchesUpdated = 0;
for (const branch of branchesFile.branches) {
const existing = await Branch.findOne({
clientKey,
code: branch.code,
});
const payload = {
clientKey,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
phoneNumber: branch.phoneNumber,
isActive: branch.isActive ?? true,
};
if (existing) {
await Branch.updateOne({ _id: existing._id }, { $set: payload });
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
branchesUpdated++;
} else {
const created = await Branch.create(payload);
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
branchesCreated++;
}
}
let expertsCreated = 0;
let expertsUpdated = 0;
let expertsSkipped = 0;
for (const expert of expertsFile.fieldExperts) {
const branchId = branchIdByCode.get(expert.branchCode);
if (!branchId) {
console.warn(
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
);
expertsSkipped++;
continue;
}
const payload = {
firstName: expert.firstName,
lastName: expert.lastName,
username: expert.nationalCode,
nationalCode: expert.nationalCode,
clientKey,
branchId,
password: hashedPassword,
mobile: expert.mobile,
role: "field_expert",
otp: "",
expertCode: expert.expertCode,
};
const existing = await FieldExpert.findOne({
clientKey,
nationalCode: expert.nationalCode,
});
if (existing) {
await FieldExpert.updateOne(
{ _id: existing._id },
{
$set: {
...payload,
// Do not rotate password on re-seed unless explicitly desired.
password: existing.password,
},
},
);
expertsUpdated++;
} else {
await FieldExpert.create(payload);
expertsCreated++;
}
}
console.log("Parsian Tehran seed completed.");
console.log({
clientCode: branchesFile.clientCode,
clientKey: String(clientKey),
branchesCreated,
branchesUpdated,
expertsCreated,
expertsUpdated,
expertsSkipped,
defaultPassword,
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
});
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -11,6 +11,24 @@ export enum CaseStatus {
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
/**
* FileMaker has collected all signatures; the file is sealed and waiting
* for a FileReviewer to complete it (accident fields → capture → video).
*/
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
/**
* V5 flow only. FileReviewer has completed the claim and the owner has signed;
* the FileMaker who created the file must now approve before fanavaran submission.
*/
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
/**
* V5 flow only. FileMaker rejected the file back to FileReviewer
* for correction (adjust pricing / back-and-forth with user and re-submit).
*/
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -34,7 +34,27 @@ export enum ClaimCaseStatus {
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */ /** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS", EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
/**
* V4 split flow only. FileMaker has uploaded all initial required documents;
* the file is sealed and waiting for a FileReviewer to pick it up (accident fields,
* capture, and final blame video). Transitions to SELECTING_OUTER_PARTS when the
* FileReviewer calls select-outer-parts after submitting accident fields.
*/
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
/**
* V5 split flow only. The claim is fully evaluated and owner has signed;
* the FileMaker who created the file must approve before fanavaran submission.
*/
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
/**
* V5 split flow only. FileMaker rejected the completed claim back to FileReviewer
* for correction (adjust pricing, re-do expert review, back-and-forth with user).
*/
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
// Final states // Final states
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -6,4 +6,8 @@ export enum RoleEnum {
COMPANY = "company", COMPANY = "company",
ADMIN = "admin", ADMIN = "admin",
USER = "user", USER = "user",
FILE_MAKER = "file_maker",
FILE_REVIEWER = "file_reviewer",
SUPER_ADMIN = "super_admin",
CALL_CENTER = "call_center",
} }

View File

@@ -1,9 +1,8 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AiService } from "./ai.service"; import { AiService } from "./ai.service";
@Module({ @Module({
imports: [HttpModule], imports: [],
providers: [AiService], providers: [AiService],
exports: [AiService], exports: [AiService],
}) })

View File

@@ -1,7 +1,8 @@
import { join } from "node:path"; import { join } from "node:path";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
import { HttpModule } from "@nestjs/axios";
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor"; import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
@@ -10,8 +11,10 @@ import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
import { ClientModule } from "./client/client.module"; import { ClientModule } from "./client/client.module";
import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module";
import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module";
import { LookupsModule } from "./lookups/lookups.module"; import { LookupsModule } from "./lookups/lookups.module";
import { PlatesModule } from "./plates/plates.module"; import { PlatesModule } from "./plates/plates.module";
import { ProfileModule } from "./profile/profile.module"; import { ProfileModule } from "./profile/profile.module";
@@ -23,17 +26,27 @@ import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin"; import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { ExpertInitiatedModule } from "./expert-initiated/expert-initiated.module";
import { DatabaseModule } from "./core/database/database.module"; import { DatabaseModule } from "./core/database/database.module";
import { AppConfigModule } from "./core/config/config.module"; import { AppConfigModule } from "./core/config/config.module";
import { SuperAdminModule } from "./super-admin/super-admin.module";
import { createHttpModuleOptions } from "./core/config/http-proxy.factory";
@Module({ @Module({
imports: [ imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
AppConfigModule, AppConfigModule,
DatabaseModule, DatabaseModule,
CronModule, CronModule,
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, "..", "files"), // process.cwd() is always the project/container root (/app in Docker),
// so the volume-mounted /app/files is resolved correctly regardless of
// where the compiled dist files live (__dirname would resolve to
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
rootPath: join(process.cwd(), "files"),
serveRoot: "/files", serveRoot: "/files",
}), }),
UsersModule, UsersModule,
@@ -46,13 +59,15 @@ import { AppConfigModule } from "./core/config/config.module";
SystemSettingsModule, SystemSettingsModule,
ExpertBlameModule, ExpertBlameModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
FanavaranModule,
ExpertClaimModule, ExpertClaimModule,
CaseExpertReportModule,
AiModule, AiModule,
ReportsModule, ReportsModule,
ExpertInsurerModule, ExpertInsurerModule,
LookupsModule, LookupsModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
// ExpertInitiatedModule, SuperAdminModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

@@ -37,6 +37,7 @@ import {
LegalRegisterDto, LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.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";
@@ -95,6 +96,7 @@ export class ActorAuthController {
* will be removed in a future release. * will be removed in a future release.
*/ */
@Post("register/genuine") @Post("register/genuine")
@UseGuards(SuperAdminGuard)
@ApiOperation({ @ApiOperation({
deprecated: true, deprecated: true,
summary: "[DEPRECATED] Genuine actor registration", summary: "[DEPRECATED] Genuine actor registration",
@@ -111,6 +113,7 @@ export class ActorAuthController {
* will be removed in a future release. * will be removed in a future release.
*/ */
@Post("register/legal") @Post("register/legal")
@UseGuards(SuperAdminGuard)
@ApiOperation({ @ApiOperation({
deprecated: true, deprecated: true,
summary: "[DEPRECATED] Legal actor registration", summary: "[DEPRECATED] Legal actor registration",
@@ -123,21 +126,24 @@ export class ActorAuthController {
} }
@Post("register/insurer") @Post("register/insurer")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: InsurerRegisterDto }) @ApiBody({ type: InsurerRegisterDto })
async registerInsurer(@Body() body: InsurerRegisterDto) { async registerInsurer(@Body() body: InsurerRegisterDto) {
return await this.actorAuthService.insurerRegister(body); return await this.actorAuthService.insurerRegister(body);
} }
/** Mock: create a field expert for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-field-expert") @Post("create-field-expert")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateFieldExpertDto }) @ApiBody({ type: CreateFieldExpertDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createFieldExpert(@Body() body: CreateFieldExpertDto) { async createFieldExpert(@Body() body: CreateFieldExpertDto) {
return await this.actorAuthService.createFieldExpertMock(body); return await this.actorAuthService.createFieldExpertMock(body);
} }
/** Mock: create a registrar for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-registrar") @Post("create-registrar")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateRegistrarDto }) @ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) { async createRegistrar(@Body() body: CreateRegistrarDto) {
@@ -150,7 +156,7 @@ export class ActorAuthController {
@ApiOperation({ @ApiOperation({
summary: "Actor login (returns access + refresh tokens)", summary: "Actor login (returns access + refresh tokens)",
description: 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.", 'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with password and one of `username` / `email` / `nationalCode`. On success the response contains the JWT pair and the resolved profile.',
}) })
@ApiBody({ @ApiBody({
type: LoginActorDto, type: LoginActorDto,
@@ -159,7 +165,8 @@ export class ActorAuthController {
examples: { examples: {
company: { company: {
summary: "Insurer / company portal", summary: "Insurer / company portal",
description: "Sample tenant credentials for the insurer (company) panel.", description:
"Sample tenant credentials for the insurer (company) panel.",
value: { value: {
role: "company", role: "company",
username: "saman_insurer@gmail.com", username: "saman_insurer@gmail.com",
@@ -190,6 +197,18 @@ export class ActorAuthController {
captcha: "a7bx2", captcha: "a7bx2",
}, },
}, },
field_expert: {
summary: "Field expert panel",
description:
"Login with email+password or nationalCode+password for seeded Parsian field experts.",
value: {
role: "field_expert",
nationalCode: "0051967839",
password: "Parsian@724",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
},
},
}, },
}) })
@ApiResponse({ @ApiResponse({

View File

@@ -27,8 +27,12 @@ import { DamageExpertDbService } from "src/users/entities/db-service/damage-expe
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 { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.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> = {};
@@ -52,6 +56,10 @@ export class ActorAuthService {
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpGeneratorService, private readonly otpService: OtpGeneratorService,
private readonly captchaChallengeService: CaptchaChallengeService, private readonly captchaChallengeService: CaptchaChallengeService,
private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService,
private readonly superAdminDbService: SuperAdminDbService,
private readonly callCenterAgentDbService: CallCenterAgentDbService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -63,7 +71,7 @@ export class ActorAuthService {
res = await this.expertDbService.findOne({ res = await this.expertDbService.findOne({
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else res = await this.expertDbService.findOne({ email: username }); else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
break; break;
case RoleEnum.DAMAGE_EXPERT: case RoleEnum.DAMAGE_EXPERT:
if (username == null && userId) if (username == null && userId)
@@ -71,14 +79,18 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else
res = await this.damageExpertDbService.findOne({ email: username }); res = await this.findActorByLoginIdentifier(
this.damageExpertDbService,
username,
);
break; break;
case RoleEnum.FIELD_EXPERT: case RoleEnum.FIELD_EXPERT:
if (username == null && userId) if (username == null && userId)
res = await this.fieldExpertDbService.findOne({ res = await this.fieldExpertDbService.findOne({
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else res = await this.fieldExpertDbService.findOne({ email: username }); else
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
break; break;
case RoleEnum.REGISTRAR: case RoleEnum.REGISTRAR:
if (username == null && userId) if (username == null && userId)
@@ -90,6 +102,37 @@ export class ActorAuthService {
case RoleEnum.COMPANY: case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username }); res = await this.insurerExpertDbService.findOne({ email: username });
break; break;
case RoleEnum.FILE_MAKER:
if (username == null && userId)
res = await this.fileMakerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.fileMakerDbService.findByLoginIdentifier(username);
break;
case RoleEnum.FILE_REVIEWER:
if (username == null && userId)
res = await this.fileReviewerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res =
await this.fileReviewerDbService.findByLoginIdentifier(username);
break;
case RoleEnum.SUPER_ADMIN:
if (username == null && userId)
res = await this.superAdminDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.superAdminDbService.findByLoginIdentifier(username);
break;
case RoleEnum.CALL_CENTER:
if (username == null && userId)
res = await this.callCenterAgentDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res = await this.callCenterAgentDbService.findByLoginIdentifier(username);
break;
default: default:
return null; return null;
} }
@@ -111,13 +154,27 @@ export class ActorAuthService {
} }
parseActorLoginUsername(body: Record<string, unknown>): string { parseActorLoginUsername(body: Record<string, unknown>): string {
const username = body?.username ?? body?.email; const username = body?.username ?? body?.email ?? body?.nationalCode;
if (typeof username !== "string" || !username.trim()) { if (typeof username !== "string" || !username.trim()) {
throw new BadRequestException("username (email) is required"); throw new BadRequestException(
"username, email, or nationalCode is required",
);
} }
return username.trim(); return username.trim();
} }
private async findActorByLoginIdentifier(
dbService: { findOne: (filter: any) => Promise<any> },
identifier: string,
) {
const id = identifier.trim();
const or: Record<string, string>[] = [{ email: id }, { username: id }];
if (/^\d{10}$/.test(id)) {
or.push({ nationalCode: id });
}
return dbService.findOne({ $or: or });
}
issueActorTokens(actor: { issueActorTokens(actor: {
_id: Types.ObjectId; _id: Types.ObjectId;
username?: string; username?: string;
@@ -129,12 +186,13 @@ export class ActorAuthService {
clientKey?: Types.ObjectId | string | null; clientKey?: Types.ObjectId | string | null;
}) { }) {
const payload = { const payload = {
username: actor.username || actor.email, username:
actor.username || actor.email || (actor as any).nationalCode || null,
sub: actor._id, sub: actor._id,
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
role: actor.role || "User", role: actor.role || "User",
userType: actor.userType || "UserType", userType: actor.userType || "UserType",
clientKey: actor.clientKey ?? null, clientKey: actor.clientKey ? String(actor.clientKey) : null,
}; };
const access_token = this.jwtService.sign(payload, { const access_token = this.jwtService.sign(payload, {

View File

@@ -13,6 +13,8 @@ import {
} from "src/helpers/iran-mobile"; } from "src/helpers/iran-mobile";
import { import {
computeOtpExpireMs, computeOtpExpireMs,
FAKE_OTP_CODE,
isFakeOtpEnabled,
isOtpExpiryActive, isOtpExpiryActive,
readOtpExpireMinutesFromEnv, readOtpExpireMinutesFromEnv,
} from "src/helpers/user-otp-expiry"; } from "src/helpers/user-otp-expiry";
@@ -109,7 +111,7 @@ export class UserAuthService {
const userExist = await this.userDbService.findOne( const userExist = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile), buildUserLookupByPhone(canonicalMobile),
); );
const otp = this.otpCreator.create(); const otp = this.createOtpForRequest();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const expireMinutes = readOtpExpireMinutesFromEnv(); const expireMinutes = readOtpExpireMinutesFromEnv();
const nowMs = Date.now(); const nowMs = Date.now();
@@ -158,7 +160,23 @@ export class UserAuthService {
return new LoginDtoRs(userExist); return new LoginDtoRs(userExist);
} }
private createOtpForRequest(): string {
if (isFakeOtpEnabled()) {
this.logger.warn(
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
);
return FAKE_OTP_CODE;
}
return this.otpCreator.create();
}
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
if (isFakeOtpEnabled()) {
this.logger.log(
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
);
return;
}
const ok = await this.smsOrchestrationService.sendAuthOtp( const ok = await this.smsOrchestrationService.sendAuthOtp(
mobile, mobile,
otp, otp,

View File

@@ -29,6 +29,11 @@ import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { CaptchaModule } from "src/captcha/captcha.module"; import { CaptchaModule } from "src/captcha/captcha.module";
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
import {
SuperAdminModel,
SuperAdminSchema,
} from "src/super-admin/entities/schema/super-admin.schema";
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */ /** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
@Global() @Global()
@@ -47,6 +52,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
schema: ClaimRequestManagementSchema, schema: ClaimRequestManagementSchema,
}, },
{ name: ClaimCase.name, schema: ClaimCaseSchema }, { name: ClaimCase.name, schema: ClaimCaseSchema },
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
]), ]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
@@ -61,6 +67,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService, JwtService,
LocalActorAuthGuard, LocalActorAuthGuard,
LocalUserAuthGuard, LocalUserAuthGuard,
SuperAdminDbService,
], ],
exports: [ exports: [
UserAuthService, UserAuthService,
@@ -68,6 +75,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService, JwtService,
LocalActorAuthGuard, LocalActorAuthGuard,
LocalUserAuthGuard, LocalUserAuthGuard,
SuperAdminDbService,
], ],
controllers: [UserAuthController, ActorAuthController], controllers: [UserAuthController, ActorAuthController],
}) })

View File

@@ -1,13 +1,34 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MaxLength } from "class-validator"; import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
export class LoginActorDto { export class LoginActorDto {
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" }) @ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
role: RoleEnum[]; role: RoleEnum[];
@ApiProperty({}) @ApiPropertyOptional({
username: string; description:
"Actor email or username. For field experts you may also send nationalCode instead.",
})
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({
description: "Alias for username when logging in with email.",
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
example: "4311402422",
description:
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
})
@IsOptional()
@IsString()
nationalCode?: string;
@ApiProperty({}) @ApiProperty({})
password: string; password: string;

View File

@@ -43,6 +43,9 @@ export class LocalActorAuthGuard implements CanActivate {
RoleEnum.COMPANY, RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT, RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR, RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER,
RoleEnum.SUPER_ADMIN,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");

View File

@@ -8,6 +8,14 @@ import { JwtService } from "@nestjs/jwt";
import { Request } from "express"; import { Request } from "express";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
const GLOBAL_GUARD_ROLES = new Set<string>([
RoleEnum.USER,
RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER
]);
@Injectable() @Injectable()
export class GlobalGuard implements CanActivate { export class GlobalGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {} constructor(private readonly jwtService: JwtService) {}
@@ -17,7 +25,7 @@ export class GlobalGuard implements CanActivate {
const token = this.extractTokenFromHeader(request); const token = this.extractTokenFromHeader(request);
if (!token) { if (!token) {
throw new UnauthorizedException(); throw new UnauthorizedException("Missing Bearer token");
} }
try { try {
@@ -25,17 +33,19 @@ export class GlobalGuard implements CanActivate {
secret: `${process.env.JWT_SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
if ( if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) {
payload.role !== RoleEnum.USER && throw new UnauthorizedException(
payload.role !== RoleEnum.FIELD_EXPERT `Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`,
) { );
throw new UnauthorizedException();
} }
request.user = payload; request.user = payload;
request.identity = request.user; request.identity = request.user;
} catch { } catch (error) {
throw new UnauthorizedException(); if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException("Invalid or expired token");
} }
return true; return true;
} }

View File

@@ -5,16 +5,19 @@ import { Reflector } from "@nestjs/core";
export class RolesGuard implements CanActivate { export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
// get the roles required
const roles = this.reflector.getAllAndOverride<string[]>("role", [ const roles = this.reflector.getAllAndOverride<string[]>("role", [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (!roles) { if (!roles?.length) {
return false; return false;
} }
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const userRoles = request.user?.role?.split(","); const role = request.user?.role;
if (!role) {
return false;
}
const userRoles = String(role).split(",");
return this.validateRoles(roles, userRoles); return this.validateRoles(roles, userRoles);
} }

View File

@@ -13,6 +13,7 @@ import { HashService } from "src/utils/hash/hash.service";
@Injectable() @Injectable()
export class CaptchaChallengeService { export class CaptchaChallengeService {
private readonly isDev: boolean; private readonly isDev: boolean;
private readonly captchaEnabled: boolean;
constructor( constructor(
private readonly captchaService: CaptchaService, private readonly captchaService: CaptchaService,
@@ -21,6 +22,7 @@ export class CaptchaChallengeService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
) { ) {
this.isDev = this.configService.get<string>("NODE_ENV") === "development"; this.isDev = this.configService.get<string>("NODE_ENV") === "development";
this.captchaEnabled = this.configService.get<string>("CAPTCHA_ENABLED") !== "false";
} }
async issue(): Promise<CaptchaResponseDto> { async issue(): Promise<CaptchaResponseDto> {
@@ -32,12 +34,17 @@ export class CaptchaChallengeService {
this.captchaService.normalizeAnswer(generated.text), this.captchaService.normalizeAnswer(generated.text),
); );
// expireAt is the MongoDB TTL sentinel. The TTL reaper fires every ~60 s, so
// setting it equal to expiresAt means Mongo can delete the document up to 60 s
// BEFORE the application-level expiry check runs — causing the intermittent
// "captchaId not found" error under load. Adding a 120 s grace buffer ensures
// the document is always present when verify() runs its own expiresAt check.
await this.captchaChallengeDbService.create({ await this.captchaChallengeDbService.create({
captchaId, captchaId,
answerHash, answerHash,
image: generated.image, image: generated.image,
expiresAt: generated.expiresAt, expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt), expireAt: new Date(generated.expiresAt + 120_000),
usedAt: null, usedAt: null,
}); });
@@ -62,6 +69,11 @@ export class CaptchaChallengeService {
captchaId: string | undefined, captchaId: string | undefined,
answer: string | undefined, answer: string | undefined,
): Promise<void> { ): Promise<void> {
// Skip captcha verification if disabled via environment variable
if (!this.captchaEnabled) {
return;
}
if (!captchaId?.trim()) { if (!captchaId?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED); throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
} }

View File

@@ -10,8 +10,8 @@ export class CaptchaChallenge {
@Prop({ required: true, unique: true, index: true }) @Prop({ required: true, unique: true, index: true })
captchaId: string; captchaId: string;
@Prop() @Prop({ required: true })
answerHash?: string; answerHash: string;
@Prop({ required: true }) @Prop({ required: true })
image: string; image: string;

View File

@@ -0,0 +1,36 @@
import { Injectable } from "@nestjs/common";
import {
createPersianPdfDocument,
persianPdfToBuffer,
} from "src/helpers/persian-pdf-document";
import {
InsurerFileReportPdfResult,
InsurerFileReportViewModel,
} from "./case-expert-report.types";
import { PR } from "./persian-report-labels";
@Injectable()
export class CaseExpertReportPdfService {
async render(model: InsurerFileReportViewModel): Promise<InsurerFileReportPdfResult> {
const pdf = createPersianPdfDocument();
pdf.addTitle(model.title);
pdf.addKeyValue(PR.publicId, model.publicId);
pdf.addKeyValue(PR.requestNo, model.requestNo);
pdf.addBlank();
for (const section of model.sections) {
pdf.addSection(section.title);
for (const field of section.fields) {
pdf.addKeyValue(field.label, field.value);
}
pdf.addBlank();
}
const buffer = await persianPdfToBuffer(pdf);
const safeId = (model.publicId || "file").replace(/[^\w.-]+/g, "_");
return {
buffer,
filename: `insurer-file-report-${safeId}.pdf`,
};
}
}

View File

@@ -0,0 +1,518 @@
import { resolveDamagedPartyRow } from "src/helpers/blame-damaged-party";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
InsurerFileReportField,
InsurerFileReportSection,
InsurerFileReportViewModel,
} from "./case-expert-report.types";
import { PR, persianFieldPath, persianStatus } from "./persian-report-labels";
const SKIP_FLATTEN_KEYS = new Set([
"_id",
"__v",
"history",
"workflow",
"evidence",
"confirmation",
]);
function asString(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (value instanceof Date) {
const [d, t] = toJalaliDateAndTime(value);
return `${d} ${t}`;
}
if (typeof value === "object") {
if (typeof (value as { toString?: () => string }).toString === "function") {
const s = String(value);
if (s !== "[object Object]") return s;
}
return undefined;
}
return String(value);
}
function formatBirthDate(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const raw = String(value);
if (/^\d{8}$/.test(raw)) {
return `${raw.slice(0, 4)}/${raw.slice(4, 6)}/${raw.slice(6, 8)}`;
}
return raw;
}
function flattenObject(
obj: unknown,
prefix = "",
depth = 0,
): InsurerFileReportField[] {
if (obj == null) return [];
if (depth > 4) {
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
}
if (Array.isArray(obj)) {
if (!obj.length) return [];
return [
{
label: persianFieldPath(prefix || "items"),
value: obj.map((item) => asString(item) ?? JSON.stringify(item)).join("، "),
},
];
}
if (typeof obj !== "object") {
return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }];
}
const rows: InsurerFileReportField[] = [];
const objRecord = obj as Record<string, unknown>;
const hasMapped =
objRecord.mapped != null && typeof objRecord.mapped === "object";
for (const [key, value] of Object.entries(objRecord)) {
if (SKIP_FLATTEN_KEYS.has(key)) continue;
if (key === "raw" && hasMapped) continue;
if (value === undefined || value === null || value === "") continue;
const path = prefix ? `${prefix}.${key}` : key;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
rows.push(...flattenObject(value, path, depth + 1));
} else {
rows.push({ label: persianFieldPath(path), value: asString(value) });
}
}
return rows;
}
function expertNameFromSnapshot(snapshot?: {
firstName?: string;
lastName?: string;
}): string | undefined {
if (!snapshot) return undefined;
const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" ");
return name || undefined;
}
function collectExpertNames(
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): string | undefined {
const names = new Set<string>();
const blameDecision = (
blame?.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
const blameExpert = expertNameFromSnapshot(
blameDecision?.expertProfileSnapshot as
| { firstName?: string; lastName?: string }
| undefined,
);
if (blameExpert) names.add(blameExpert);
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
for (const key of ["damageExpertReplyFinal", "damageExpertReply"] as const) {
const reply = evaluation?.[key] as Record<string, unknown> | undefined;
if (!reply) continue;
const actor = (reply.actorDetail as { actorName?: string } | undefined)
?.actorName;
if (actor) names.add(actor);
const snap = expertNameFromSnapshot(
reply.expertProfileSnapshot as
| { firstName?: string; lastName?: string }
| undefined,
);
if (snap) names.add(snap);
}
return names.size ? [...names].join(", ") : undefined;
}
function inquiryRoleData(
inquiries: Record<string, unknown> | undefined,
key: string,
role?: string,
): Record<string, unknown> | undefined {
const block = inquiries?.[key] as Record<string, unknown> | undefined;
const data = block?.data as Record<string, unknown> | undefined;
if (!data) return undefined;
if (role && data[role] && typeof data[role] === "object") {
return data[role] as Record<string, unknown>;
}
return data;
}
/** Prefer mapped Tejarat fields; avoid duplicating the entire raw blob in PDF. */
function pickInquiryReportPayload(
inquiry?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!inquiry) return undefined;
const mapped = inquiry.mapped;
if (mapped && typeof mapped === "object" && !Array.isArray(mapped)) {
return mapped as Record<string, unknown>;
}
const { raw: _raw, ...rest } = inquiry;
return Object.keys(rest).length ? rest : inquiry;
}
function licenseFieldsFromInquiry(
inquiry?: Record<string, unknown>,
): { licenseType?: string; licenseDate?: string } {
if (!inquiry) return {};
const candidates = [
inquiry.LicenseType,
inquiry.licenseType,
inquiry.Type,
inquiry.type,
inquiry.LicenseCategory,
inquiry.licenseCategory,
];
const licenseType = candidates.find((v) => v != null && v !== "");
const dateCandidates = [
inquiry.IssueDate,
inquiry.issueDate,
inquiry.LicenseIssueDate,
inquiry.licenseIssueDate,
inquiry.ExpireDate,
inquiry.expireDate,
];
const licenseDate = dateCandidates.find((v) => v != null && v !== "");
return {
licenseType: asString(licenseType),
licenseDate: asString(licenseDate),
};
}
function buildOwnerSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const person = damagedParty?.person;
const money = claim?.money as
| { sheba?: string; nationalCodeOfInsurer?: string }
| undefined;
const claimOwner = claim?.owner as { fullName?: string } | undefined;
return {
title: PR.ownerSection,
fields: [
{ label: PR.name, value: person?.fullName ?? claimOwner?.fullName },
{ label: PR.phone, value: person?.phoneNumber },
{
label: PR.nationalCode,
value: person?.nationalCodeOfInsurer ?? money?.nationalCodeOfInsurer,
},
{
label: PR.birthDate,
value: formatBirthDate(person?.insurerBirthday ?? person?.birthday),
},
{ label: PR.sheba, value: money?.sheba },
],
};
}
function buildDriverSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
blame?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
const person = damagedParty?.person;
if (!person || person.driverIsInsurer !== false) return undefined;
const role = damagedParty?.role ?? PartyRole.FIRST;
const licenseInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"drivingLicence",
role,
);
const { licenseType, licenseDate } = licenseFieldsFromInquiry(licenseInquiry);
return {
title: PR.driverSection,
fields: [
{ label: PR.name, value: person.fullName },
{
label: PR.licenseType,
value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined),
},
{
label: PR.licenseDate,
value: licenseDate ?? person.driverLicense,
},
{ label: PR.phone, value: person.phoneNumber },
{ label: PR.nationalCode, value: person.nationalCodeOfDriver },
{ label: PR.birthDate, value: formatBirthDate(person.driverBirthday) },
{ label: PR.licenseNumber, value: person.driverLicense },
],
};
}
function buildInsuranceSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const role = damagedParty?.role ?? PartyRole.FIRST;
const insurance = damagedParty?.insurance ?? {};
const carBodyLegacy = blame?.carBodyInsuranceDetail as
| Record<string, unknown>
| undefined;
const thirdPartyInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
);
const carBodyInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
);
const claimThirdParty = inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
);
const claimCarBody = inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
);
const fields: InsurerFileReportField[] = [
...flattenObject(insurance, "party.insurance"),
...flattenObject(
(insurance as { carBodyInsurance?: unknown }).carBodyInsurance,
"party.insurance.carBodyInsurance",
),
...flattenObject(
pickInquiryReportPayload(thirdPartyInquiry),
"inquiry.thirdParty",
),
...flattenObject(pickInquiryReportPayload(carBodyInquiry), "inquiry.carBody"),
...flattenObject(
pickInquiryReportPayload(claimThirdParty),
"claim.inquiry.thirdParty",
),
...flattenObject(
pickInquiryReportPayload(claimCarBody),
"claim.inquiry.carBody",
),
...flattenObject(carBodyLegacy, "blame.carBodyInsuranceDetail"),
];
const deduped = dedupeFields(fields);
return {
title: PR.insuranceSection,
fields: deduped.length ? deduped : [{ label: PR.data, value: PR.empty }],
};
}
function buildVehicleSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const partyVehicle = damagedParty?.vehicle;
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
const fields = dedupeFields([
...flattenObject(claimVehicle, "claim.vehicle"),
...flattenObject(partyVehicle, "party.vehicle"),
]);
return {
title: PR.vehicleSection,
fields: fields.length ? fields : [{ label: PR.data, value: PR.empty }],
};
}
function buildAccidentReportSection(
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
damagedParty?: ReturnType<typeof resolveDamagedPartyRow>,
): InsurerFileReportSection {
const statement = damagedParty?.statement as
| Record<string, unknown>
| undefined;
const location = damagedParty?.location;
const snapshotAccident = (
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
)?.accident;
const blameDecision = (
blame?.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
const decisionFields = blameDecision?.fields as
| Record<string, unknown>
| undefined;
const accidentDate =
asString(statement?.accidentDate) ??
asString(snapshotAccident?.date) ??
asString(blame?.createdAtFormatted) ??
asString(blame?.createdAt);
const accidentTime =
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time);
const fields: InsurerFileReportField[] = [
{ label: PR.date, value: accidentDate },
{ label: PR.time, value: accidentTime },
{
label: PR.experts,
value: collectExpertNames(blame, claim),
},
{
label: PR.location,
value:
location?.lat != null && location?.lon != null
? `${location.lat}، ${location.lon}`
: undefined,
},
{
label: PR.weather,
value:
asString(statement?.weatherCondition) ??
asString(snapshotAccident?.weatherCondition),
},
{
label: PR.road,
value:
asString(statement?.roadCondition) ??
asString(snapshotAccident?.roadCondition),
},
{
label: PR.light,
value:
asString(statement?.lightCondition) ??
asString(snapshotAccident?.lightCondition),
},
{
label: PR.blameStatus,
value: persianStatus(blame?.blameStatus),
},
{
label: PR.claimStatus,
value: persianStatus(claim?.claimStatus),
},
{ label: PR.expertDecision, value: asString(blameDecision?.description) },
{
label: PR.accidentWay,
value: asString(
(decisionFields?.accidentWay as { label?: string } | undefined)?.label ??
(
snapshotAccident?.classification as {
accidentWay?: { label?: string };
}
)?.accidentWay?.label,
),
},
{
label: PR.accidentReason,
value: asString(
(decisionFields?.accidentReason as { label?: string } | undefined)
?.label ??
(
snapshotAccident?.classification as {
accidentReason?: { label?: string };
}
)?.accidentReason?.label,
),
},
{
label: PR.accidentType,
value: asString(
(decisionFields?.accidentType as { label?: string } | undefined)?.label ??
(
snapshotAccident?.classification as {
accidentType?: { label?: string };
}
)?.accidentType?.label,
),
},
{
label: PR.damageExpertDate,
value: asString(
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReplyFinal as Record<string, unknown> | undefined
)?.submittedAt ??
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReply as Record<string, unknown> | undefined
)?.submittedAt,
),
},
{
label: PR.damageExpertNotes,
value: asString(
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReplyFinal as Record<string, unknown> | undefined
)?.description ??
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReply as Record<string, unknown> | undefined
)?.description,
),
},
{
label: PR.partyDescription,
value: asString(statement?.description),
},
];
return {
title: PR.accidentSection,
fields: dedupeFields(fields),
};
}
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
const seen = new Set<string>();
const out: InsurerFileReportField[] = [];
for (const field of fields) {
const value = asString(field.value);
if (!value) continue;
const key = `${field.label}::${value}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ label: field.label, value });
}
return out;
}
export function buildInsurerFileReport(
file: {
overview?: Record<string, unknown>;
blame?: Record<string, unknown>;
claim?: Record<string, unknown>;
},
): InsurerFileReportViewModel {
const overview = file.overview ?? {};
const blame = file.blame ?? null;
const claim = file.claim ?? null;
const damagedParty = blame ? resolveDamagedPartyRow(blame) : null;
const sections: InsurerFileReportSection[] = [
buildOwnerSection(damagedParty, claim),
];
const driverSection = buildDriverSection(damagedParty, blame);
if (driverSection) sections.push(driverSection);
sections.push(
buildInsuranceSection(damagedParty, blame, claim),
buildVehicleSection(damagedParty, claim),
buildAccidentReportSection(blame, claim, damagedParty),
);
return {
title: PR.reportTitle,
publicId: asString(overview.publicId) ?? PR.empty,
requestNo: asString(overview.requestNo),
sections,
};
}

View File

@@ -0,0 +1,68 @@
import {
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
StreamableFile,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiProduces,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { CaseExpertReportService } from "./case-expert-report.service";
@ApiTags("expert-insurer-panel")
@Controller("expert-insurer")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.COMPANY)
export class CaseExpertReportInsurerController {
constructor(
private readonly caseExpertReportService: CaseExpertReportService,
) {}
@Get("files/:publicId/report.pdf")
@ApiOperation({
summary: "Download insurer file report PDF",
description:
"Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.",
})
@ApiParam({ name: "publicId" })
@ApiProduces("application/pdf")
@ApiResponse({ status: 200, description: "PDF file" })
@ApiResponse({ status: 404, description: "File not found for this publicId" })
async downloadInsurerReport(
@CurrentUser() insurer: { clientKey?: string },
@Param("publicId") publicId: string,
): Promise<StreamableFile> {
try {
const { buffer, filename } =
await this.caseExpertReportService.generateForInsurer(
publicId,
insurer,
);
return new StreamableFile(buffer, {
type: "application/pdf",
disposition: `attachment; filename="${filename}"`,
});
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to generate insurer file report PDF",
);
}
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
import { CaseExpertReportInsurerController } from "./case-expert-report.controller";
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
import { CaseExpertReportService } from "./case-expert-report.service";
@Module({
imports: [ExpertInsurerModule],
controllers: [CaseExpertReportInsurerController],
providers: [CaseExpertReportService, CaseExpertReportPdfService],
exports: [CaseExpertReportService],
})
export class CaseExpertReportModule {}

View File

@@ -0,0 +1,30 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
import { buildInsurerFileReport } from "./case-expert-report.builder";
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
import { InsurerFileReportPdfResult } from "./case-expert-report.types";
@Injectable()
export class CaseExpertReportService {
constructor(
private readonly expertInsurerService: ExpertInsurerService,
private readonly pdfService: CaseExpertReportPdfService,
) {}
async generateForInsurer(
publicId: string,
actor: { clientKey?: string },
): Promise<InsurerFileReportPdfResult> {
const clientKey = actor?.clientKey;
if (!clientKey) {
throw new NotFoundException("Insurer context not found");
}
const file = await this.expertInsurerService.retrieveFileDetailsByPublicId(
clientKey,
publicId,
);
const model = buildInsurerFileReport(file);
return this.pdfService.render(model);
}
}

View File

@@ -0,0 +1,21 @@
export type InsurerFileReportField = {
label: string;
value?: string | number | null;
};
export type InsurerFileReportSection = {
title: string;
fields: InsurerFileReportField[];
};
export type InsurerFileReportViewModel = {
title: string;
publicId: string;
requestNo?: string;
sections: InsurerFileReportSection[];
};
export type InsurerFileReportPdfResult = {
buffer: Buffer;
filename: string;
};

View File

@@ -0,0 +1,196 @@
export const PR = {
reportTitle: "گزارش پرونده بیمه گر",
publicId: "شناسه عمومی",
requestNo: "شماره درخواست",
empty: "-",
ownerSection: "مالک خودروی زیان دیده",
driverSection: "راننده خودروی زیان دیده",
insuranceSection: "اطلاعات بیمه (بدنه و شخص ثالث)",
vehicleSection: "اطلاعات خودرو",
accidentSection: "گزارش حادثه",
name: "نام",
phone: "شماره تلفن",
nationalCode: "کد ملی",
birthDate: "تاریخ تولد",
sheba: "شماره شبا",
licenseType: "نوع گواهینامه",
licenseDate: "تاریخ گواهینامه",
licenseNumber: "شماره گواهینامه",
driverLicense: "گواهینامه راننده",
data: "اطلاعات",
date: "تاریخ",
time: "زمان",
experts: "کارشناس(ان)",
location: "موقعیت (عرض و طول جغرافیایی)",
weather: "وضعیت آب و هوا",
road: "وضعیت جاده",
light: "وضعیت نور",
blameStatus: "وضعیت مقصر",
claimStatus: "وضعیت خسارت",
expertDecision: "نظر کارشناس مقصر",
accidentWay: "نحوه برخورد",
accidentReason: "علت حادثه",
accidentType: "نوع حادثه",
damageExpertDate: "تاریخ ارزیابی کارشناس خسارت",
damageExpertNotes: "توضیحات کارشناس خسارت",
partyDescription: "توضیحات طرف",
} as const;
const KEY_LABELS: Record<string, string> = {
policyNumber: "شماره بیمه‌نامه",
company: "شرکت بیمه",
insurerCompany: "شرکت بیمه‌گر",
startDate: "تاریخ شروع",
endDate: "تاریخ پایان",
financialCeiling: "سقف مالی",
coverages: "پوشش‌ها",
plateId: "پلاک",
name: "نام",
model: "مدل",
type: "نوع",
carName: "نام خودرو",
carModel: "مدل خودرو",
carType: "نوع خودرو",
isNew: "خودرو نو",
isNewCar: "خودرو نو",
leftDigits: "دو رقم چپ پلاک",
centerAlphabet: "حرف پلاک",
centerDigits: "سه رقم وسط پلاک",
ir: "کد ایران",
plate: "پلاک",
CompanyName: "نام شرکت",
PolicyNumber: "شماره بیمه‌نامه",
PolicyStartDate: "تاریخ شروع بیمه",
PolicyEndDate: "تاریخ پایان بیمه",
LicenseType: "نوع گواهینامه",
licenseType: "نوع گواهینامه",
IssueDate: "تاریخ صدور",
issueDate: "تاریخ صدور",
ExpireDate: "تاریخ انقضا",
expireDate: "تاریخ انقضا",
party: "طرف",
insurance: "بیمه",
carBodyInsurance: "بیمه بدنه",
inquiry: "استعلام",
thirdParty: "شخص ثالث",
carBody: "بدنه",
claim: "خسارت",
vehicle: "خودرو",
blame: "مقصر",
items: "موارد",
value: "مقدار",
mapped: "نتیجه استعلام",
has: "موجود",
updatedAt: "به‌روزرسانی",
source: "منبع",
PrntPlcyCmpDocNo: "شماره سند شرکت",
MapTypNam: "نام نوع خودرو",
MtrNum: "شماره موتور",
ShsNum: "شماره شاسی",
vin: "VIN",
VinNumberField: "VIN",
DisFnYrPrcnt: "درصد تخفیف مالی",
DisLfYrPrcnt: "درصد تخفیف جانی",
DisPrsnYrPrcnt: "درصد تخفیف شخص ثالث",
MapVehicleSystemName: "سیستم خودرو",
LfCvrCptl: "سرمایه پوشش جانی",
FnCvrCptl: "سرمایه پوشش مالی",
PrsnCvrCptl: "سرمایه پوشش شخص ثالث",
PersonCvrCptl: "سرمایه پوشش شخص",
LifeCvrCptl: "سرمایه پوشش حیات",
FinancialCvrCptl: "سرمایه پوشش مالی",
VehicleSystemCode: "کد سیستم خودرو",
CarGroupCode: "کد گروه خودرو",
CylCnt: "تعداد سیلندر",
LastCompanyDocumentNumber: "شماره سند آخرین شرکت",
UsageCode: "کد کاربری",
MapUsageCode: "کد کاربری نگاشت‌شده",
MapUsageName: "نام کاربری",
Plk1: "دو رقم چپ پلاک",
Plk2: "حرف پلاک",
Plk3: "سه رقم وسط پلاک",
PlkSrl: "کد ایران پلاک",
SystemField: "سیستم",
TypeField: "تیپ",
UsageField: "کاربری",
MainColorField: "رنگ اصلی",
SecondColorField: "رنگ فرعی",
ModelField: "مدل",
CapacityField: "ظرفیت",
CacityField: "ظرفیت",
CylinderNumberField: "تعداد سیلندر",
EngineNumberField: "شماره موتور",
ChassisNumberField: "شماره شاسی",
InstallDateField: "تاریخ نصب",
AxelNumberField: "تعداد محور",
WheelNumberField: "تعداد چرخ",
CompanyCode: "کد شرکت",
SatrtDate: "تاریخ شروع",
EndDate: "تاریخ پایان",
PolicyHealthLossCount: "تعداد خسارت جانی",
PolicyFinancialLossCount: "تعداد خسارت مالی",
PolicyPersonLossCount: "تعداد خسارت شخص ثالث",
Tonage: "تناژ",
ThirdPolicyCode: "کد بیمه‌نامه ثالث",
SystemCodeCii: "کد سیستم",
SystemNameCii: "نام سیستم",
TypeCodeCii: "کد نوع",
TypeNameCii: "نام نوع",
UsageNameCii: "نام کاربری",
UsageCodeCii: "کد کاربری",
ModelCii: "مدل",
StatusTypeCode: "کد وضعیت",
label_fa: "برچسب فارسی",
catalogKey: "کلید کاتالوگ",
};
const STATUS_LABELS: Record<string, string> = {
AGREED: "توافق",
DISAGREEMENT: "اختلاف نظر",
UNKNOWN: "نامشخص",
APPROVED: "تأیید شده",
REJECTED: "رد شده",
NEEDS_REVISION: "نیاز به بازبینی",
UNDER_REVIEW: "در حال بررسی",
PENDING: "در انتظار",
true: "بله",
false: "خیر",
};
/** Turn `party.insurance.policyNumber` into a Persian label. */
export function persianFieldPath(path: string): string {
if (!path) return PR.data;
const parts = path.split(".").filter(Boolean);
const last = parts[parts.length - 1] ?? path;
const translatedLast = KEY_LABELS[last] ?? last;
const inquiryRoot = parts.find(
(p) => p === "thirdParty" || p === "carBody" || p === "mapped",
);
if (inquiryRoot === "thirdParty") {
return `بیمه شخص ثالث / ${translatedLast}`;
}
if (inquiryRoot === "carBody") {
return `بیمه بدنه / ${translatedLast}`;
}
if (parts[0] === "party" && parts[1] === "insurance") {
return `بیمه / ${translatedLast}`;
}
if (parts[0] === "claim" && parts[1] === "vehicle") {
return `خودرو / ${translatedLast}`;
}
if (parts[0] === "party" && parts[1] === "vehicle") {
return `خودرو / ${translatedLast}`;
}
if (parts.length === 1) return translatedLast;
const translated = parts.map((part) => KEY_LABELS[part] ?? part);
return translated.join(" / ");
}
export function persianStatus(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
const key = String(value);
return STATUS_LABELS[key] ?? key;
}

View File

@@ -23,6 +23,7 @@ import {
ApiQuery, ApiQuery,
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiExcludeController,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
@@ -39,8 +40,9 @@ import { UserObjectionDto } from "./dto/user-objection.dto";
import { InPersonVisitDto } from "./dto/in-person-visit.dto"; import { InPersonVisitDto } from "./dto/in-person-visit.dto";
import { UserRatingDto } from "./dto/user-rating.dto"; import { UserRatingDto } from "./dto/user-rating.dto";
@ApiExcludeController()
@Controller("claim-request-management") @Controller("claim-request-management")
@ApiTags("claim-request-management") // @ApiTags("claim-request-management")
@Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
@UseGuards(ClaimAccessGuard, RolesGuard) @UseGuards(ClaimAccessGuard, RolesGuard)
@ApiBearerAuth() @ApiBearerAuth()
@@ -49,8 +51,8 @@ export class ClaimRequestManagementController {
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
) {} ) {}
@ApiParam({ name: "blameId" }) // @ApiParam({ name: "blameId" })
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Post("/:blameId") @Post("/:blameId")
async createClaimRequest( async createClaimRequest(
@Param("blameId") requestId: string, @Param("blameId") requestId: string,
@@ -63,10 +65,10 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ type: CarDamagePartDto }) // @ApiBody({ type: CarDamagePartDto })
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Patch("/car-part-damage/:claimRequestID") @Patch("/car-part-damage/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async carPartDamage( async carPartDamage(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@Body() body: CarDamagePartDto, @Body() body: CarDamagePartDto,
@@ -79,7 +81,7 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("/car-other-part") @Get("/car-other-part")
async getCarOtherParts() { async getCarOtherParts() {
const carOtherPart = await readFile( const carOtherPart = await readFile(
@@ -89,8 +91,8 @@ export class ClaimRequestManagementController {
return carOtherPart; return carOtherPart;
} }
@ApiBody({ type: OtherCarDamagePartDto }) // @ApiBody({ type: OtherCarDamagePartDto })
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
@@ -108,7 +110,7 @@ export class ClaimRequestManagementController {
}), }),
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ deprecated: true }) // @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,
@@ -124,32 +126,32 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("required-documents-status/:claimRequestID") @Get("required-documents-status/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) { async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
return await this.claimRequestManagementService.getRequiredDocumentsStatus( return await this.claimRequestManagementService.getRequiredDocumentsStatus(
requestId, requestId,
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("car-part-image-required/:claimRequestID") @Get("car-part-image-required/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async getImageRequired(@Param("claimRequestID") requestId) { async getImageRequired(@Param("claimRequestID") requestId) {
return await this.claimRequestManagementService.getImageRequiredList( return await this.claimRequestManagementService.getImageRequiredList(
requestId, requestId,
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
@@ -169,14 +171,14 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // // @ApiParam({ name: "claimRequestID" })
@ApiQuery({ // @ApiQuery({
name: "documentType", // name: "documentType",
enum: ClaimRequiredDocumentType, // enum: ClaimRequiredDocumentType,
description: "Type of required document to upload", // description: "Type of required document to upload",
}) // })
@ApiOperation({ deprecated: true }) // @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,
@@ -195,14 +197,14 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
@@ -223,13 +225,13 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
@ApiParam({ // @ApiParam({
name: "partId", // name: "partId",
description: "The ID of the specific car part being photographed.", // description: "The ID of the specific car part being photographed.",
}) // })
@ApiOperation({ deprecated: true }) // @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,
@@ -246,14 +248,14 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
@@ -268,9 +270,9 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Patch("car-capture/:claimRequestID") @Patch("car-capture/:claimRequestID")
async captureVideoCapture( async captureVideoCapture(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -282,22 +284,22 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @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 }) // @ApiOperation({ deprecated: true })
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) { myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
return this.claimRequestManagementService.requestDetails(requestId, user); return this.claimRequestManagementService.requestDetails(requestId, user);
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Put("request/reply/:claimRequestId") @Put("request/reply/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
@@ -314,12 +316,12 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiBody({ // @ApiBody({
type: UserCommentDto, // type: UserCommentDto,
description: "if partId null , you can upload video capture", // description: "if partId null , you can upload video capture",
}) // })
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async submitReply( async submitReply(
@Param("claimRequestId") requestId, @Param("claimRequestId") requestId,
@Body() body, @Body() body,
@@ -334,14 +336,14 @@ export class ClaimRequestManagementController {
// ); // );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Put("request/resend/:claimRequestId/objection") @Put("request/resend/:claimRequestId/objection")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiConsumes("application/json") // @ApiConsumes("application/json")
@ApiBody({ // @ApiBody({
type: UserObjectionDto, // type: UserObjectionDto,
description: "Objection details with optional new parts", // description: "Objection details with optional new parts",
}) // })
async handleUserObjection( async handleUserObjection(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() userObjectionDto: UserObjectionDto, @Body() userObjectionDto: UserObjectionDto,
@@ -352,25 +354,25 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Patch("request/resend/:claimRequestId") @Patch("request/resend/:claimRequestId")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] }) // @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
@ApiQuery({ name: "partId", required: false }) // @ApiQuery({ name: "partId", required: false })
@ApiQuery({ name: "documentName", required: false }) // @ApiQuery({ name: "documentName", required: false })
@ApiQuery({ name: "side", required: false }) // @ApiQuery({ name: "side", required: false })
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { // file: {
type: "string", // type: "string",
format: "binary", // format: "binary",
}, // },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
storage: diskStorage({ storage: diskStorage({
@@ -403,10 +405,10 @@ export class ClaimRequestManagementController {
* User satisfaction rating for a completed claim file. * User satisfaction rating for a completed claim file.
* Only the damaged user (claim owner) can rate their claim after it is closed. * Only the damaged user (claim owner) can rate their claim after it is closed.
*/ */
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Put("request/:claimRequestId/user-rating") @Put("request/:claimRequestId/user-rating")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UserRatingDto }) // @ApiBody({ type: UserRatingDto })
async addUserRating( async addUserRating(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() ratingDto: UserRatingDto, @Body() ratingDto: UserRatingDto,
@@ -419,22 +421,22 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Patch("request/reply/:claimRequestId/:partId/upload-factor") @Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiParam({ name: "partId" }) // @ApiParam({ name: "partId" })
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { // file: {
type: "string", // type: "string",
format: "binary", // format: "binary",
}, // },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
storage: diskStorage({ storage: diskStorage({
@@ -462,9 +464,9 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ type: InPersonVisitDto }) // @ApiBody({ type: InPersonVisitDto })
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Patch(":id/visit") @Patch(":id/visit")
async inPersonVisit( async inPersonVisit(
@Param("id") requestId: string, @Param("id") requestId: string,
@@ -479,26 +481,25 @@ export class ClaimRequestManagementController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("branches/:insuranceId") @Get("branches/:insuranceId")
// @ApiParam({ name: "insuranceId" })
async insuranceBranches(@Param("insuranceId") insuranceId: string) { async insuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches( return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
insuranceId,
);
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("fanavaran-submit/:claimRequestId") @Get("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) { async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
return await this.claimRequestManagementService.fanavaranSubmit( return await this.claimRequestManagementService.fanavaranSubmit(
claimRequestId, claimRequestId,
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Post("fanavaran-submit/:claimRequestId") @Post("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) { async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
return await this.claimRequestManagementService.submitToFanavaran( return await this.claimRequestManagementService.submitToFanavaran(
claimRequestId, claimRequestId,

View File

@@ -1,5 +1,8 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { AiModule } from "src/ai/ai.module"; import { AiModule } from "src/ai/ai.module";
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
@@ -7,6 +10,8 @@ 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 { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
@@ -50,11 +55,18 @@ import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { HttpModule } from "@nestjs/axios"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
FanavaranAuditModule,
FanavaranLookupModule,
PublicIdModule, PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,
@@ -97,6 +109,8 @@ import { HttpModule } from "@nestjs/axios";
ClaimRequestManagementController, ClaimRequestManagementController,
ClaimRequestManagementV2Controller, ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller, RegistrarClaimV1Controller,
ExpertInitiatedClaimMirrorController,
RegistrarClaimMirrorController,
], ],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,

View File

@@ -13,10 +13,19 @@ import {
Get, Get,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
UploadedFiles,
} from "@nestjs/common"; } from "@nestjs/common";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import {
import { FileInterceptor } from "@nestjs/platform-express"; ApiBearerAuth,
ApiParam,
ApiTags,
ApiOperation,
ApiResponse,
ApiBody,
ApiConsumes,
} from "@nestjs/swagger";
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "node:path"; import { extname } from "node:path";
import { Types } from "mongoose"; import { Types } from "mongoose";
@@ -33,9 +42,15 @@ import {
SelectOuterPartsV2Dto, SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto, SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto"; } 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 { import {
CapturePartV2Dto, CapturePartV2Dto,
CapturePartV2ResponseDto, CapturePartV2ResponseDto,
@@ -52,7 +67,13 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@Controller("v2/claim-request-management") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT) @Roles(
RoleEnum.USER,
RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER,
)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -63,7 +84,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get My Claims (V2)", summary: "Get My Claims (V2)",
description: description:
"Claims for the current user (or field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", "Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -97,7 +118,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get Claim Details (V2)", summary: "Get Claim Details (V2)",
description: 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.", "Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -142,7 +163,10 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, description: "Claim returned to expert queue" }) @ApiResponse({ status: 200, description: "Claim returned to expert queue" })
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" }) @ApiResponse({
status: 400,
description: "Resend requires uploads or wrong step",
})
async acknowledgeExpertResend( async acknowledgeExpertResend(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any, @CurrentUser() user: any,
@@ -156,15 +180,19 @@ export class ClaimRequestManagementV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to acknowledge expert resend", 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). * V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
* Accepts multipart/form-data so optional supporting invoices can be attached in the same request.
*/ */
@Put("request/:claimRequestId/objection") @Put("request/:claimRequestId/objection")
@ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Submit user objection (V2)", summary: "Submit user objection (V2)",
description: description:
@@ -172,30 +200,75 @@ export class ClaimRequestManagementV2Controller {
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\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" + "`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" + "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`.", "Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.\n\n" +
"**Invoices:** optionally attach up to 5 supporting documents (images/PDFs) as `invoices` file fields. Stored in `evaluation.objection.invoices[]` and visible to the reviewing expert.",
}) })
@ApiParam({ @ApiParam({
name: "claimRequestId", name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)", description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011", example: "507f1f77bcf86cd799439011",
}) })
@ApiBody({ type: UserObjectionV2Dto }) @ApiBody({
description:
"Objection payload as multipart form fields. `objectionParts` and `newParts` are JSON-encoded strings.",
schema: {
type: "object",
properties: {
objectionParts: {
type: "string",
description:
'JSON-encoded array of disputed priced parts. Example: `[{"partId":201,"reason":"Price too high"}]`',
},
newParts: {
type: "string",
description:
'JSON-encoded array of new parts to add. Example: `[{"partName":"سپر جلو","side":"front"}]`',
},
invoices: {
type: "array",
items: { type: "string", format: "binary" },
description: "Up to 5 supporting invoice or document files (image or PDF).",
},
},
},
})
@ApiResponse({ status: 200, description: "Objection stored" }) @ApiResponse({ status: 200, description: "Objection stored" })
@ApiResponse({ status: 400, description: "No active resend or empty payload" }) @ApiResponse({
status: 400,
description: "No active resend or empty payload",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" }) @ApiResponse({ status: 409, description: "Objection already submitted" })
@UseInterceptors(
FilesInterceptor("invoices", 5, {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-objection-invoices",
filename: (req, file, callback) => {
const unique = Date.now() + "-" + Math.round(Math.random() * 1e6);
const ex = extname(file.originalname);
callback(null, `objection-invoice-${unique}${ex}`);
},
}),
}),
)
async submitUserObjectionV2( async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto, @Body() body: UserObjectionV2Dto,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFiles() invoices?: Express.Multer.File[],
) { ) {
for (const file of invoices ?? []) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
}
try { try {
return await this.claimRequestManagementService.handleUserObjectionV2( return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user, user,
invoices,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -222,7 +295,10 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ type: UserRatingDto }) @ApiBody({ type: UserRatingDto })
@ApiResponse({ status: 200, description: "Rating saved" }) @ApiResponse({ status: 200, description: "Rating saved" })
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" }) @ApiResponse({
status: 400,
description: "Claim not completed or invalid scores",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Rating already submitted" }) @ApiResponse({ status: 409, description: "Rating already submitted" })
@@ -270,21 +346,32 @@ export class ClaimRequestManagementV2Controller {
type: "object", type: "object",
required: ["sign", "agree", "branchId"], required: ["sign", "agree", "branchId"],
properties: { properties: {
sign: { type: "string", format: "binary", description: "Signature image" }, sign: {
type: "string",
format: "binary",
description: "Signature image",
},
agree: { agree: {
type: "boolean", type: "boolean",
description: "true to accept expert pricing and complete the claim", description: "true to accept expert pricing and complete the claim",
}, },
branchId: { branchId: {
type: "string", type: "string",
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)", description:
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011", example: "507f1f77bcf86cd799439011",
}, },
}, },
}, },
}) })
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" }) @ApiResponse({
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" }) 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: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Already signed" }) @ApiResponse({ status: 409, description: "Already signed" })
@@ -314,7 +401,9 @@ export class ClaimRequestManagementV2Controller {
} }
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image"); await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed = const agreed =
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try { try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId, claimRequestId,
@@ -453,8 +542,7 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ @ApiBody({
type: SelectOuterPartsV2Dto, type: SelectOuterPartsV2Dto,
description: description: "Selected vehicle type + selected outer part IDs from catalog",
"Selected vehicle type + selected outer part IDs from catalog",
examples: { examples: {
example1: { example1: {
summary: "Sedan - minor front damage", summary: "Sedan - minor front damage",
@@ -522,9 +610,7 @@ export class ClaimRequestManagementV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error error instanceof Error ? error.message : "Failed to select outer parts",
? error.message
: "Failed to select outer parts",
); );
} }
} }
@@ -638,9 +724,7 @@ Optional: upload car green card file in the same step.
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error error instanceof Error ? error.message : "Failed to select other parts",
? error.message
: "Failed to select other parts",
); );
} }
} }
@@ -862,7 +946,7 @@ Returns status of each item (uploaded/captured or not).
type: "string", type: "string",
example: "front", example: "front",
description: description:
'For angle: front/back/left/right. For part: hood/front_bumper/etc.', "For angle: front/back/left/right. For part: hood/front_bumper/etc.",
}, },
file: { file: {
type: "string", type: "string",
@@ -1005,7 +1089,10 @@ Returns status of each item (uploaded/captured or not).
description: "Video uploaded successfully", description: "Video uploaded successfully",
type: VideoCaptureV2ResponseDto, type: VideoCaptureV2ResponseDto,
}) })
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" }) @ApiResponse({
status: 400,
description: "Wrong workflow step or missing file",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Video already uploaded" }) @ApiResponse({ status: 409, description: "Video already uploaded" })
@@ -1025,7 +1112,9 @@ Returns status of each item (uploaded/captured or not).
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload car capture video", error instanceof Error
? error.message
: "Failed to upload car capture video",
); );
} }
} }

View File

@@ -1,34 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/** /**
* V2 DTO for capturing car angle or damaged part * V2 DTO for capturing car angle or damaged part
*/ */
export class CapturePartV2Dto { export class CapturePartV2Dto {
@ApiProperty({ @ApiProperty({
description: 'Type of capture: angle or part', description: "Type of capture: angle or part",
example: 'angle', example: "angle",
enum: ['angle', 'part'], enum: ["angle", "part"],
}) })
@IsNotEmpty({ message: 'Capture type is required' }) @IsNotEmpty({ message: "Capture type is required" })
@IsEnum(['angle', 'part'], { @IsEnum(["angle", "part"], {
message: 'Capture type must be either "angle" or "part"', message: 'Capture type must be either "angle" or "part"',
}) })
captureType: 'angle' | 'part'; captureType: "angle" | "part";
@ApiProperty({ @ApiProperty({
description: 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.', '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" })
@IsString({ message: 'Capture key must be a string' }) @IsString({ message: "Capture key must be a string" })
captureKey: string; captureKey: string;
@ApiProperty({ @ApiProperty({
type: 'string', type: "string",
format: 'binary', format: "binary",
description: 'Image file (JPG, PNG)', description: "Image file (JPG, PNG)",
}) })
file: Express.Multer.File; file: Express.Multer.File;
} }
@@ -38,51 +38,58 @@ export class CapturePartV2Dto {
*/ */
export class CapturePartV2ResponseDto { export class CapturePartV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Claim request ID', description: "Claim request ID",
example: '507f1f77bcf86cd799439011', example: "507f1f77bcf86cd799439011",
}) })
claimRequestId: string; claimRequestId: string;
@ApiProperty({ @ApiProperty({
description: 'Type of capture', description: "Type of capture",
example: 'angle', example: "angle",
}) })
captureType: string; captureType: string;
@ApiProperty({ @ApiProperty({
description: 'Key of what was captured', description: "Key of what was captured",
example: 'front', example: "front",
}) })
captureKey: string; captureKey: string;
@ApiProperty({ @ApiProperty({
description: 'File URL', description: "File URL",
example: 'http://localhost:3000/files/captures/front-1234567890.jpg', example: "http://localhost:3000/files/captures/front-1234567890.jpg",
}) })
fileUrl: string; fileUrl: string;
@ApiProperty({ @ApiProperty({
description: 'Whether all captures are now complete', description: "Whether all captures are now complete",
example: false, example: false,
}) })
allCapturesComplete: boolean; allCapturesComplete: boolean;
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: "Current workflow step",
example: 'CAPTURE_PART_DAMAGES', example: "CAPTURE_PART_DAMAGES",
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: "Success message",
example: 'Angle captured successfully. 6 captures remaining.', example: "Angle captured successfully. 6 captures remaining.",
}) })
message: string; message: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.', description:
"True when expert-requested part resends are complete and the claim returned to the expert queue.",
}) })
expertResendComplete?: boolean; expertResendComplete?: boolean;
@ApiPropertyOptional({
description:
"Best-effort Fanavaran attachment upload result. Local capture still succeeds when this contains a warning.",
})
fanavaranAttachment?: unknown;
} }
/** /**
@@ -90,20 +97,20 @@ export class CapturePartV2ResponseDto {
*/ */
export class VideoCaptureV2ResponseDto { export class VideoCaptureV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Claim case ID', description: "Claim case ID",
example: '507f1f77bcf86cd799439011', example: "507f1f77bcf86cd799439011",
}) })
claimRequestId: string; claimRequestId: string;
@ApiProperty({ @ApiProperty({
description: 'ID of the stored video document (claim-video-capture)', description: "ID of the stored video document (claim-video-capture)",
example: '507f1f77bcf86cd799439012', example: "507f1f77bcf86cd799439012",
}) })
videoId: string; videoId: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: "Success message",
example: 'Video capture uploaded successfully.', example: "Video capture uploaded successfully.",
}) })
message: string; message: string;
} }

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class CreateClaimFromBlameResponseDto { export class CreateClaimFromBlameResponseDto {
@ApiProperty({ @ApiProperty({
@@ -23,4 +23,10 @@ export class CreateClaimFromBlameResponseDto {
example: "Claim request created successfully", example: "Claim request created successfully",
}) })
message: string; message: string;
@ApiPropertyOptional({
description:
"Best-effort Fanavaran early submit result. Claim creation still succeeds when this contains a warning.",
})
fanavaran?: unknown;
} }

View File

@@ -106,14 +106,16 @@ export class SelectOtherPartsV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Sheba number (masked for security)', description: 'Sheba number (masked for security)',
example: 'IR12************1234', example: 'IR12************1234',
required: false,
}) })
shebaNumber: string; shebaNumber?: string;
@ApiProperty({ @ApiProperty({
description: 'National code of owner (masked)', description: 'National code of owner (masked)',
example: '12******90', example: '12******90',
required: false,
}) })
nationalCodeOfOwner: string; nationalCodeOfOwner?: string;
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsOptional,
} from "class-validator";
import { OtherCarPart } from "./select-other-parts-v2.dto";
/**
* V3 in-person expert flow: other parts only (sheba/national code collected during run-inquiries).
*/
export class SelectOtherPartsV3Dto {
@ApiPropertyOptional({
description: "Array of selected other damaged parts (non-body parts)",
example: ["engine", "suspension", "headlight"],
enum: OtherCarPart,
isArray: true,
maxItems: 11,
})
@IsOptional()
@IsArray({ message: "otherParts must be an array" })
@ArrayMaxSize(11, { message: "Maximum 11 other parts can be selected" })
@IsEnum(OtherCarPart, {
each: true,
message: "Invalid part name. Must be one of the valid other car parts",
})
otherParts?: OtherCarPart[];
}

View File

@@ -1,5 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator'; import {
IsArray,
IsEnum,
IsNotEmpty,
ArrayMinSize,
ArrayUnique,
IsOptional,
IsInt,
} from "class-validator";
import { import {
ClaimVehicleTypeV2, ClaimVehicleTypeV2,
OuterPartSideV2, OuterPartSideV2,
@@ -12,27 +20,27 @@ import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
*/ */
export enum OuterCarPart { export enum OuterCarPart {
// Hood // Hood
HOOD = 'hood', HOOD = "hood",
// Doors // Doors
FRONT_RIGHT_DOOR = 'front_right_door', FRONT_RIGHT_DOOR = "front_right_door",
FRONT_LEFT_DOOR = 'front_left_door', FRONT_LEFT_DOOR = "front_left_door",
REAR_RIGHT_DOOR = 'rear_right_door', REAR_RIGHT_DOOR = "rear_right_door",
REAR_LEFT_DOOR = 'rear_left_door', REAR_LEFT_DOOR = "rear_left_door",
// Bumpers // Bumpers
FRONT_BUMPER = 'front_bumper', FRONT_BUMPER = "front_bumper",
REAR_BUMPER = 'rear_bumper', REAR_BUMPER = "rear_bumper",
// Fenders // Fenders
FRONT_RIGHT_FENDER = 'front_right_fender', FRONT_RIGHT_FENDER = "front_right_fender",
FRONT_LEFT_FENDER = 'front_left_fender', FRONT_LEFT_FENDER = "front_left_fender",
REAR_RIGHT_FENDER = 'rear_right_fender', REAR_RIGHT_FENDER = "rear_right_fender",
REAR_LEFT_FENDER = 'rear_left_fender', REAR_LEFT_FENDER = "rear_left_fender",
// Trunk & Roof // Trunk & Roof
TRUNK = 'trunk', TRUNK = "trunk",
ROOF = 'roof', ROOF = "roof",
} }
/** /**
@@ -41,38 +49,38 @@ export enum OuterCarPart {
*/ */
export class SelectOuterPartsV2Dto { export class SelectOuterPartsV2Dto {
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged outer car parts', description: "Array of selected damaged outer car parts",
example: ['hood', 'front_right_door', 'rear_bumper', 'roof'], example: ["hood", "front_right_door", "rear_bumper", "roof"],
enum: OuterCarPart, enum: OuterCarPart,
isArray: true, isArray: true,
minItems: 1, minItems: 1,
maxItems: 13, maxItems: 13,
}) })
@IsOptional() @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" })
@IsEnum(OuterCarPart, { @IsEnum(OuterCarPart, {
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({ @ApiProperty({
description: 'Selected outer part IDs from catalog', description: "Selected outer part IDs from catalog",
example: [9, 10, 4], example: [9, 10, 4],
type: [Number], type: [Number],
required: false, required: false,
}) })
@IsOptional() @IsOptional()
@IsArray({ message: 'selectedPartIds must be an array' }) @IsArray({ message: "selectedPartIds must be an array" })
@ArrayMinSize(1, { message: 'At least one part ID must be selected' }) @ArrayMinSize(1, { message: "At least one part ID must be selected" })
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' }) @ArrayUnique({ message: "Duplicate part IDs are not allowed" })
@IsInt({ each: true, message: 'Each selected part ID must be an integer' }) @IsInt({ each: true, message: "Each selected part ID must be an integer" })
selectedPartIds?: number[]; selectedPartIds?: number[];
@ApiProperty({ @ApiProperty({
description: 'Vehicle type for validating available outer parts', description: "Vehicle type for validating available outer parts",
enum: ClaimVehicleTypeV2, enum: ClaimVehicleTypeV2,
required: true, required: true,
}) })
@@ -86,14 +94,14 @@ export class SelectOuterPartsV2Dto {
*/ */
export class SelectOuterPartsV2ResponseDto { export class SelectOuterPartsV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Claim request ID', description: "Claim request ID",
example: '507f1f77bcf86cd799439011', example: "507f1f77bcf86cd799439011",
}) })
claimRequestId: string; claimRequestId: string;
@ApiProperty({ @ApiProperty({
description: 'Public ID shared across blame and claim', description: "Public ID shared across blame and claim",
example: 'A14235', example: "A14235",
}) })
publicId: string; publicId: string;
@@ -105,29 +113,36 @@ export class SelectOuterPartsV2ResponseDto {
selectedParts: DamageSelectedPartV2BodyDto[]; selectedParts: DamageSelectedPartV2BodyDto[];
@ApiProperty({ @ApiProperty({
description: 'Selected part IDs', description: "Selected part IDs",
example: [9, 7, 11], example: [9, 7, 11],
type: [Number], type: [Number],
}) })
selectedPartIds: number[]; selectedPartIds: number[];
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: "Current workflow step",
example: 'SELECT_OUTER_PARTS', example: "SELECT_OUTER_PARTS",
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Next possible workflow step', description: "Next possible workflow step",
example: 'SELECT_OTHER_PARTS', example: "SELECT_OTHER_PARTS",
}) })
nextStep: string; nextStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: "Success message",
example: 'Outer parts selected successfully. Please proceed to select other parts.', example:
"Outer parts selected successfully. Please proceed to select other parts.",
}) })
message: string; message: string;
@ApiPropertyOptional({
description:
"Best-effort Fanavaran damage-case submit result. Selecting parts still succeeds when this contains a warning.",
})
fanavaranDamageCase?: unknown;
} }
export class SetClaimVehicleTypeV2Dto { export class SetClaimVehicleTypeV2Dto {

View File

@@ -1,26 +1,26 @@
import { ApiProperty, ApiPropertyOptional } 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";
/** /**
* V2 DTO for uploading required document * V2 DTO for uploading required document
*/ */
export class UploadRequiredDocumentV2Dto { export class UploadRequiredDocumentV2Dto {
@ApiProperty({ @ApiProperty({
description: 'Document type/key', description: "Document type/key",
example: 'car_green_card', example: "car_green_card",
enum: ClaimRequiredDocumentType, enum: ClaimRequiredDocumentType,
}) })
@IsNotEmpty({ message: 'Document key is required' }) @IsNotEmpty({ message: "Document key is required" })
@IsEnum(ClaimRequiredDocumentType, { @IsEnum(ClaimRequiredDocumentType, {
message: 'Invalid document type', message: "Invalid document type",
}) })
documentKey: ClaimRequiredDocumentType; documentKey: ClaimRequiredDocumentType;
@ApiProperty({ @ApiProperty({
type: 'string', type: "string",
format: 'binary', format: "binary",
description: 'Image file (JPG, PNG, PDF)', description: "Image file (JPG, PNG, PDF)",
}) })
file: Express.Multer.File; file: Express.Multer.File;
} }
@@ -30,43 +30,51 @@ export class UploadRequiredDocumentV2Dto {
*/ */
export class UploadRequiredDocumentV2ResponseDto { export class UploadRequiredDocumentV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Claim request ID', description: "Claim request ID",
example: '507f1f77bcf86cd799439011', example: "507f1f77bcf86cd799439011",
}) })
claimRequestId: string; claimRequestId: string;
@ApiProperty({ @ApiProperty({
description: 'Document key that was uploaded', description: "Document key that was uploaded",
example: 'car_green_card', example: "car_green_card",
}) })
documentKey: string; documentKey: string;
@ApiProperty({ @ApiProperty({
description: 'File URL', description: "File URL",
example: 'http://localhost:3000/files/documents/car-green-card-1234567890.jpg', example:
"http://localhost:3000/files/documents/car-green-card-1234567890.jpg",
}) })
fileUrl: string; fileUrl: string;
@ApiProperty({ @ApiProperty({
description: 'Whether all required documents are now uploaded', description: "Whether all required documents are now uploaded",
example: false, example: false,
}) })
allDocumentsUploaded: boolean; allDocumentsUploaded: boolean;
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: "Current workflow step",
example: 'UPLOAD_REQUIRED_DOCUMENTS', example: "UPLOAD_REQUIRED_DOCUMENTS",
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: "Success message",
example: 'Document uploaded successfully. 12 documents remaining.', example: "Document uploaded successfully. 12 documents remaining.",
}) })
message: string; message: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.', description:
"True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.",
}) })
expertResendComplete?: boolean; expertResendComplete?: boolean;
@ApiPropertyOptional({
description:
"Best-effort Fanavaran attachment upload result. Local document upload still succeeds when this contains a warning.",
})
fanavaranAttachment?: unknown;
} }

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type, Transform } from "class-transformer";
import { import {
IsArray, IsArray,
IsOptional, IsOptional,
@@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto"; import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/** /**
* V2 user objection body — same shape as v1 {@link UserObjectionDto} * V2 user objection body — submitted as multipart/form-data.
* with nested validation enabled for the v2 controller pipeline. * `objectionParts` and `newParts` are JSON-encoded strings in the form fields.
* Optional `invoices` files are uploaded as a `invoices` file array.
*/ */
export class UserObjectionV2Dto { export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] }) @ApiPropertyOptional({
type: [UserObjectionPartDto],
description:
"JSON-encoded array of disputed priced parts. Pass as a JSON string in the multipart field.",
})
@Validate(HasObjectionEntriesConstraint) @Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => UserObjectionPartDto) @Type(() => UserObjectionPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
objectionParts?: UserObjectionPartDto[]; objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] }) @ApiPropertyOptional({
type: [NewPartDto],
description:
"JSON-encoded array of new parts to add. Pass as a JSON string in the multipart field.",
})
@Validate(HasObjectionEntriesConstraint) @Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => NewPartDto) @Type(() => NewPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
newParts?: NewPartDto[]; newParts?: NewPartDto[];
} }

View File

@@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart {
export const ClaimUserObjectionNewPartSchema = export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart); SchemaFactory.createForClass(ClaimUserObjectionNewPart);
// ---------------------------------------------------------------------------
// Objection invoice (supporting document uploaded alongside the objection)
// ---------------------------------------------------------------------------
@Schema({ _id: false })
export class ClaimObjectionInvoice {
@Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() })
fileId: Types.ObjectId;
@Prop({ type: String, required: true })
path: string;
@Prop({ type: String, required: true })
fileName: string;
@Prop({ type: Date, default: () => new Date() })
uploadedAt: Date;
}
export const ClaimObjectionInvoiceSchema =
SchemaFactory.createForClass(ClaimObjectionInvoice);
/** /**
* Full user objection payload (matches v1 DTO: objectionParts + newParts). * Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}. * Stored on {@link ClaimEvaluation.objection}.
@@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload {
@Prop({ type: Date, default: () => new Date() }) @Prop({ type: Date, default: () => new Date() })
submittedAt?: Date; submittedAt?: Date;
/** Optional supporting invoices uploaded at objection time. */
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
invoices?: ClaimObjectionInvoice[];
} }
export const ClaimUserObjectionPayloadSchema = export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload); SchemaFactory.createForClass(ClaimUserObjectionPayload);

View File

@@ -57,7 +57,7 @@ export class ClaimWorkflow {
@Prop({ type: Date }) @Prop({ type: Date })
lockedAt?: Date; lockedAt?: Date;
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */ /** Lock expiry used by UI countdown (typically lockedAt + 30m). */
@Prop({ type: Date }) @Prop({ type: Date })
expiredAt?: Date; expiredAt?: Date;
@@ -68,7 +68,7 @@ export class ClaimWorkflow {
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot; preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
/** /**
* First damage expert who called review assign; kept after the 15m lock expires * First damage expert who called review assign; kept after the 30m lock expires
* so no other expert can take the case while it remains in the expert queue. * so no other expert can take the case while it remains in the expert queue.
*/ */
@Prop({ type: ClaimActorLockSchema }) @Prop({ type: ClaimActorLockSchema })

View File

@@ -58,6 +58,64 @@ export class RequiredDocumentRef {
export const RequiredDocumentRefSchema = export const RequiredDocumentRefSchema =
SchemaFactory.createForClass(RequiredDocumentRef); SchemaFactory.createForClass(RequiredDocumentRef);
@Schema({ _id: false })
export class FanavaranSyncStage {
@Prop({ type: String })
status?: "pending" | "success" | "failed" | "skipped";
@Prop({ type: Date })
lastTriedAt?: Date;
@Prop({ type: String })
lastError?: string;
@Prop({ type: Number })
claimId?: number;
@Prop({ type: Number })
claimNo?: number;
@Prop({ type: Number })
dmgCaseId?: number;
@Prop({ type: Number })
expertiseId?: number;
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
files?: unknown[];
@Prop({ type: MongooseSchema.Types.Mixed })
response?: unknown;
@Prop({ type: Number, default: 0 })
retryCount?: number;
@Prop({ type: Number, default: 2 })
maxRetries?: number;
@Prop({ type: Date })
nextRetryAt?: Date;
}
export const FanavaranSyncStageSchema =
SchemaFactory.createForClass(FanavaranSyncStage);
@Schema({ _id: false })
export class FanavaranSyncState {
@Prop({ type: FanavaranSyncStageSchema })
baseClaim?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
damageCase?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
attachments?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
expertise?: FanavaranSyncStage;
}
export const FanavaranSyncStateSchema =
SchemaFactory.createForClass(FanavaranSyncState);
@Schema({ @Schema({
collection: "claimCases", collection: "claimCases",
timestamps: true, timestamps: true,
@@ -108,6 +166,22 @@ export class ClaimCase {
@Prop({ type: String, index: true }) @Prop({ type: String, index: true })
blameRequestNo?: string; blameRequestNo?: string;
/**
* Set when this claim was created by a field expert on behalf of the damaged
* party (expert-initiated IN_PERSON flow). Used to scope the expert's panel
* access to only their own initiated files.
*/
@Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId;
/**
* The damaged party's userId, resolved from the blame at claim-creation time.
* Stored here so view/list access does not require an extra blame lookup.
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
*/
@Prop({ type: Types.ObjectId, index: true })
damagedPartyUserId?: Types.ObjectId;
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) }) @Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
workflow?: ClaimWorkflow; workflow?: ClaimWorkflow;
@@ -126,6 +200,15 @@ export class ClaimCase {
@Prop({ type: Number }) @Prop({ type: Number })
claimId?: number; claimId?: number;
@Prop({ type: Number })
dmgCaseId?: number;
@Prop({ type: Number })
expertiseId?: number;
@Prop({ type: FanavaranSyncStateSchema, default: () => ({}) })
fanavaranSync?: FanavaranSyncState;
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) }) @Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
damage?: ClaimDamageSelection; damage?: ClaimDamageSelection;
@@ -168,6 +251,20 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true }) @Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId; createdByRegistrarId?: Types.ObjectId;
/**
* V5 split flow: when true, the claim must be approved by the FileMaker
* who created the file before fanavaran submission is allowed.
*/
@Prop({ type: Boolean, default: false })
requiresFileMakerApproval?: boolean;
/**
* V5 split flow: ObjectId of the FileMaker who must approve this claim.
* Set when the FileReviewer uploads the blame accident video in the V5 flow.
*/
@Prop({ type: Types.ObjectId, index: true })
fileMakerApprovalActorId?: 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

@@ -0,0 +1,635 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import {
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Patch,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import {
OuterPartCatalogItemDto,
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
import {
SelectOtherPartsV2Dto,
SelectOtherPartsV2ResponseDto,
} from "./dto/select-other-parts-v2.dto";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "./dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
/**
* Expert-initiated claim flow that mirrors the normal user claim API
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
* claim pages by only swapping the route prefix:
*
* `v2/claim-request-management/*` -> `v2/expert-initiated/claim-request-management/*`
*
* The field expert fills the claim (parts, captures, documents) on behalf of the
* damaged party for a claim created from an expert-initiated IN_PERSON blame.
* After the expert submits the data, the file follows the exact normal review
* lifecycle: the damage expert prices it, and the owner can later object / resend
* from their own account through the normal user endpoints.
*/
@ApiTags("expert-initiated claim (mirror v2)")
@Controller("v2/expert-initiated/claim-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FIELD_EXPERT)
export class ExpertInitiatedClaimMirrorController {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
@Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" })
@ApiOperation({
summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame",
description:
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
})
async createFromBlame(
@Param("blameRequestId") blameRequestId: string,
@CurrentUser() expert: any,
) {
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
blameRequestId,
expert,
);
}
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("car-other-part")
@ApiOperation({
summary: "Get other (non-body) parts catalog",
description:
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
})
@ApiResponse({ status: 200, description: "Other parts catalog" })
async getCarOtherParts() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Get("branches/:insuranceId")
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
})
@ApiResponse({ status: 200, description: "List of branches for insurer" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
return this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@Patch("select-outer-parts/:claimRequestId")
@ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
description: `
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
**Purpose:** Expert selects which outer car parts (body parts) were damaged on behalf of the damaged party.
**Validations:**
- Claim must exist
- Current workflow step must be CLAIM_CREATED
- Parts array must contain at least 1 part
- No duplicate parts allowed
- Parts cannot be re-selected once submitted
**After Success:**
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
type: SelectOuterPartsV2Dto,
description:
"Selected vehicle type + selected outer part IDs from catalog",
examples: {
example1: {
summary: "Sedan - minor front damage",
value: {
carType: "sedan",
selectedPartIds: [19, 21, 16],
},
},
example2: {
summary: "SUV - left side impact",
value: {
carType: "suv",
selectedPartIds: [102, 103, 104, 107],
},
},
example3: {
summary: "Hatchback - rear-end collision",
value: {
carType: "hatchback",
selectedPartIds: [225, 226, 210],
},
},
example4: {
summary: "Pickup - two sides + roof",
value: {
carType: "pickup",
selectedPartIds: [319, 312, 330],
},
},
},
})
@ApiResponse({
status: 200,
description: "Outer parts selected successfully",
type: SelectOuterPartsV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
@ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Outer parts already selected" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() expert: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV2(
claimRequestId,
body,
expert.sub,
expert,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiOperation({
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
description: `
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** Expert selects non-body damaged parts and provides bank information for payment on behalf of the damaged party.
Optional: upload car green card file in the same step.
**Validations:**
- Claim must exist
- Current workflow step must be SELECT_OTHER_PARTS
- Bank information must not have been submitted previously
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
- National code must be exactly 10 digits
**Valid Other Parts (Optional):**
- engine, suspension, brake_system, electrical
- radiator, transmission, exhaust
- headlight, taillight, mirror, glass
**After Success:**
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-required-document",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `other-parts-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
description:
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{ type: "array", items: { type: "string" } },
{ type: "string", description: "JSON string array for multipart" },
],
example: ["engine", "suspension"],
},
sheba: { type: "string", example: "IR123456789012345678901234" },
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
file: { type: "string", format: "binary" },
},
required: ["sheba", "nationalCodeOfInsurer"],
},
})
@ApiResponse({
status: 200,
description: "Other parts and bank information saved successfully",
type: SelectOtherPartsV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
@ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Bank information already submitted" })
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() expert: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> {
// Green-card photo is optional here — the helper no-ops on missing file.
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
expert.sub,
expert,
file,
);
}
@Get("capture-requirements/:claimRequestId")
@ApiOperation({
summary: "Get Capture Requirements (V2)",
description: `
**Get list of what needs to be captured:**
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
- Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not).
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiResponse({
status: 200,
description: "Capture requirements retrieved successfully",
type: GetCaptureRequirementsV2ResponseDto,
})
@ApiResponse({ status: 403, description: "User is not the claim owner" })
@ApiResponse({ status: 404, description: "Claim case not found" })
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() expert: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
expert.sub,
expert,
);
}
@Post("upload-document/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload Required Document (V2 - Step 5)",
description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["documentKey", "file"],
properties: {
documentKey: {
type: "string",
enum: [
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
],
example: "damaged_driving_license_front",
},
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiResponse({
status: 200,
description: "Document uploaded successfully",
type: UploadRequiredDocumentV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step" })
@ApiResponse({ status: 409, description: "Document already uploaded" })
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() expert: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
expert.sub,
expert,
);
}
@Post("capture-part/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
**Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2
**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
},
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiResponse({
status: 200,
description: "Capture saved successfully",
type: CapturePartV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step" })
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() expert: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV2(
claimRequestId,
body,
file,
expert.sub,
expert,
);
}
// ─── Owner signature on expert pricing ───────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@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, false to reject" },
branchId: { type: "string", description: "Insurer branch ID" },
},
},
})
@ApiOperation({
summary: "Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
description:
"Field expert submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the expert's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed =
typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
expert.sub,
expert,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Patch("car-capture/:claimRequestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
description:
"Upload a walk-around video of the damaged car during the capture phase. Allowed at any point after outer parts are selected.",
})
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
async carCapture(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() expert: any,
) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
return this.claimRequestManagementService.setVideoCaptureV2(
claimRequestId,
file,
expert.sub,
expert,
);
}
}

View File

@@ -0,0 +1,63 @@
import { BadRequestException } from "@nestjs/common";
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
describe("selectLatestActiveFanavaranPolicy", () => {
const today = "2026-06-30";
it("selects the policy with the latest EndDate when newest is first", () => {
const selected = selectLatestActiveFanavaranPolicy(
[
{ PolicyId: 4826286, EndDate: "1405/09/23" },
{ PolicyId: 3719458, EndDate: "1404/09/06" },
{ PolicyId: 2800731, EndDate: "1403/09/05" },
],
today,
);
expect(selected.policyId).toBe(4826286);
expect(selected.endDate).toBe("1405/09/23");
});
it("selects the policy with the latest EndDate when newest is last", () => {
const selected = selectLatestActiveFanavaranPolicy(
[
{ PolicyId: 2800731, EndDate: "1403/09/05" },
{ PolicyId: 3719458, EndDate: "1404/09/06" },
{ PolicyId: 4826286, EndDate: "1405/09/23" },
],
today,
);
expect(selected.policyId).toBe(4826286);
});
it("rejects when no policies are returned", () => {
expect(() => selectLatestActiveFanavaranPolicy([], today)).toThrow(
BadRequestException,
);
});
it("rejects when the latest policy is expired", () => {
expect(() =>
selectLatestActiveFanavaranPolicy(
[
{ PolicyId: 3719458, EndDate: "1404/09/06" },
{ PolicyId: 2800731, EndDate: "1403/09/05" },
],
today,
),
).toThrow(BadRequestException);
});
it("rejects when the latest policy has no valid PolicyId", () => {
expect(() =>
selectLatestActiveFanavaranPolicy(
[
{ PolicyId: 3719458, EndDate: "1404/09/06" },
{ PolicyId: null, EndDate: "1405/09/23" },
],
today,
),
).toThrow(BadRequestException);
});
});

View File

@@ -0,0 +1,95 @@
import { BadRequestException } from "@nestjs/common";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { gregorianDateInIran } from "src/helpers/iran-datetime";
export type FanavaranPolicyInquiryRow = {
PolicyId?: unknown;
EndDate?: unknown;
};
export type SelectedFanavaranPolicy = {
policy: FanavaranPolicyInquiryRow;
policyId: number;
endDate: string;
endDateGregorian: string;
};
type DatedFanavaranPolicy = {
policy: FanavaranPolicyInquiryRow;
endDate: string;
endDateGregorian: string;
};
const NO_POLICY_MESSAGE =
"No Fanavaran policies were found for the insurer national code. PolicyId is required; contact the administrator.";
const EXPIRED_POLICY_MESSAGE =
"The latest insurance policy is expired and cannot be sent to Fanavaran. Contact the administrator.";
const INVALID_POLICY_MESSAGE =
"Fanavaran policy inquiry returned policies without a valid PolicyId or EndDate. PolicyId is required; contact the administrator.";
function parsePolicyId(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === "string" && value.trim() === "") return null;
const id = Number(value);
return Number.isFinite(id) && id > 0 ? id : null;
}
function normalizeEndDate(value: unknown): {
endDate: string;
endDateGregorian: string;
} | null {
if (value === null || value === undefined) return null;
const endDate = String(value).trim();
if (!endDate) return null;
const endDateGregorian = jalaliToGregorianDate(endDate);
if (!endDateGregorian) return null;
return { endDate, endDateGregorian };
}
export function selectLatestActiveFanavaranPolicy(
policies: unknown,
todayGregorian: string = gregorianDateInIran(new Date()),
): SelectedFanavaranPolicy {
if (!Array.isArray(policies) || policies.length === 0) {
throw new BadRequestException(NO_POLICY_MESSAGE);
}
const candidates = policies
.map((policy) => {
if (!policy || typeof policy !== "object") return null;
const row = policy as FanavaranPolicyInquiryRow;
const endDate = normalizeEndDate(row.EndDate);
if (!endDate) return null;
return {
policy: row,
endDate: endDate.endDate,
endDateGregorian: endDate.endDateGregorian,
};
})
.filter((policy): policy is DatedFanavaranPolicy => policy !== null);
if (candidates.length === 0) {
throw new BadRequestException(INVALID_POLICY_MESSAGE);
}
const latest = candidates.reduce((currentLatest, candidate) =>
candidate.endDateGregorian > currentLatest.endDateGregorian
? candidate
: currentLatest,
);
if (latest.endDateGregorian < todayGregorian) {
throw new BadRequestException(EXPIRED_POLICY_MESSAGE);
}
const policyId = parsePolicyId(latest.policy.PolicyId);
if (policyId === null) {
throw new BadRequestException(INVALID_POLICY_MESSAGE);
}
return { ...latest, policyId };
}

View File

@@ -0,0 +1,529 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import {
OuterPartCatalogItemDto,
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
import {
SelectOtherPartsV2Dto,
SelectOtherPartsV2ResponseDto,
} from "./dto/select-other-parts-v2.dto";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "./dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
/**
* Registrar claim flow that mirrors the normal user claim API
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
* claim pages by only swapping the route prefix:
*
* `v2/claim-request-management/*` -> `v2/registrar/claim-request-management/*`
*
* The registrar fills the claim (parts, captures, documents) on behalf of the
* damaged party for a claim created from a registrar-initiated IN_PERSON blame.
* After submission the file follows the exact normal review lifecycle:
* the damage expert prices it, and the owner can later object/resend.
* The registrar's job ends here — no reviewing panel is needed.
*/
@ApiTags("registrar claim (mirror v2)")
@Controller("v2/registrar/claim-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.REGISTRAR)
export class RegistrarClaimMirrorController {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
@Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" })
@ApiOperation({
summary: "[Registrar mirror] Create claim from registrar-initiated IN_PERSON blame",
description:
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
})
async createFromBlame(
@Param("blameRequestId") blameRequestId: string,
@CurrentUser() registrar: any,
) {
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
blameRequestId,
registrar,
);
}
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("car-other-part")
@ApiOperation({
summary: "Get other (non-body) parts catalog",
description:
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
})
@ApiResponse({ status: 200, description: "Other parts catalog" })
async getCarOtherParts() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Get("branches/:insuranceId")
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options.",
})
@ApiResponse({ status: 200, description: "List of branches for insurer" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
return this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@Patch("select-outer-parts/:claimRequestId")
@ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
description: `
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
**Purpose:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party.
**After Success:**
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
type: SelectOuterPartsV2Dto,
description: "Selected vehicle type + selected outer part IDs from catalog",
examples: {
example1: {
summary: "Sedan - minor front damage",
value: { carType: "sedan", selectedPartIds: [19, 21, 16] },
},
example2: {
summary: "SUV - left side impact",
value: { carType: "suv", selectedPartIds: [102, 103, 104, 107] },
},
example3: {
summary: "Hatchback - rear-end collision",
value: { carType: "hatchback", selectedPartIds: [225, 226, 210] },
},
example4: {
summary: "Pickup - two sides + roof",
value: { carType: "pickup", selectedPartIds: [319, 312, 330] },
},
},
})
@ApiResponse({
status: 200,
description: "Outer parts selected successfully",
type: SelectOuterPartsV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
@ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Outer parts already selected" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() registrar: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV2(
claimRequestId,
body,
registrar.sub,
registrar,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiOperation({
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
description: `
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party.
Optional: upload car green card file in the same step.
**Valid Other Parts (Optional):**
- engine, suspension, brake_system, electrical
- radiator, transmission, exhaust
- headlight, taillight, mirror, glass
**After Success:**
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-required-document",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `other-parts-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
description:
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{ type: "array", items: { type: "string" } },
{ type: "string", description: "JSON string array for multipart" },
],
example: ["engine", "suspension"],
},
sheba: { type: "string", example: "IR123456789012345678901234" },
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
file: { type: "string", format: "binary" },
},
required: ["sheba", "nationalCodeOfInsurer"],
},
})
@ApiResponse({
status: 200,
description: "Other parts and bank information saved successfully",
type: SelectOtherPartsV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
@ApiResponse({ status: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Bank information already submitted" })
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() registrar: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
registrar.sub,
registrar,
file,
);
}
@Get("capture-requirements/:claimRequestId")
@ApiOperation({
summary: "Get Capture Requirements (V2)",
description: `
**Get list of what needs to be captured:**
- Required documents (10 remaining at the documents step for third-party)
- Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not).
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiResponse({
status: 200,
description: "Capture requirements retrieved successfully",
type: GetCaptureRequirementsV2ResponseDto,
})
@ApiResponse({ status: 403, description: "Access denied" })
@ApiResponse({ status: 404, description: "Claim case not found" })
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() registrar: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
registrar.sub,
registrar,
);
}
@Post("upload-document/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload Required Document (V2 - Step 5)",
description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE.
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["documentKey", "file"],
properties: {
documentKey: {
type: "string",
enum: [
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
],
example: "damaged_driving_license_front",
},
file: { type: "string", format: "binary" },
},
},
})
@ApiResponse({
status: 200,
description: "Document uploaded successfully",
type: UploadRequiredDocumentV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step" })
@ApiResponse({ status: 409, description: "Document already uploaded" })
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() registrar: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
registrar.sub,
registrar,
);
}
@Post("capture-part/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
**Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
},
file: { type: "string", format: "binary" },
},
},
})
@ApiResponse({
status: 200,
description: "Capture saved successfully",
type: CapturePartV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Invalid workflow step" })
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() registrar: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV2(
claimRequestId,
body,
file,
registrar.sub,
registrar,
);
}
@Patch("car-capture/:claimRequestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
description:
"Upload a walk-around video of the damaged car during the capture phase.",
})
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
async carCapture(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() registrar: any,
) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
return this.claimRequestManagementService.setVideoCaptureV2(
claimRequestId,
file,
registrar.sub,
registrar,
);
}
}

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, Use
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 { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiConsumes, ApiExcludeController } 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";
@@ -15,7 +15,8 @@ import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto"; import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
@ApiTags("registrar-claim (v1)") @ApiExcludeController()
// @ApiTags("registrar-claim (v1)")
@Controller("registrar-claim") @Controller("registrar-claim")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@@ -24,7 +25,7 @@ export class RegistrarClaimV1Controller {
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {} constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" }) // @ApiParam({ name: "blameRequestId" })
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) { createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1( return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
blameRequestId, blameRequestId,
@@ -42,7 +43,7 @@ export class RegistrarClaimV1Controller {
} }
@Patch("select-outer-parts/:claimRequestId") @Patch("select-outer-parts/:claimRequestId")
@ApiBody({ type: SelectOuterPartsV2Dto }) // @ApiBody({ type: SelectOuterPartsV2Dto })
selectOuter( selectOuter(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto, @Body() body: SelectOuterPartsV2Dto,
@@ -57,7 +58,7 @@ export class RegistrarClaimV1Controller {
} }
@Patch("select-other-parts/:claimRequestId") @Patch("select-other-parts/:claimRequestId")
@ApiBody({ type: SelectOtherPartsV2Dto }) // @ApiBody({ type: SelectOtherPartsV2Dto })
selectOther( selectOther(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto, @Body() body: SelectOtherPartsV2Dto,
@@ -91,7 +92,7 @@ export class RegistrarClaimV1Controller {
}), }),
}), }),
) )
@ApiOperation({ summary: "Registrar uploads required claim document" }) // @ApiOperation({ summary: "Registrar uploads required claim document" })
uploadDoc( uploadDoc(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto, @Body() body: UploadRequiredDocumentV2Dto,
@@ -108,7 +109,7 @@ export class RegistrarClaimV1Controller {
} }
@Post("capture-part/:claimRequestId") @Post("capture-part/:claimRequestId")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },

View File

@@ -0,0 +1,97 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Put,
} from "@nestjs/common";
import {
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { ClientService } from "./client.service";
import {
ClientExternalInquiriesCatalogDto,
ClientExternalInquiriesListDto,
ClientExternalInquiriesViewDto,
ExternalInquiryFlagsDto,
UpdateClientExternalInquiriesDto,
} from "./dto/client-external-inquiries.dto";
/**
* Per-insurer external inquiry toggles (public for now — lock down when super-admin exists).
*/
@ApiTags("client-external-inquiries")
@Controller("client")
export class ClientExternalInquiriesController {
constructor(private readonly clientService: ClientService) {}
@Get("external-inquiries/catalog")
@ApiOperation({
summary: "List supported external inquiry kinds and API paths",
description: "No auth (temporary). Describes global master switch + per-client flags.",
})
@ApiResponse({ status: 200, type: ClientExternalInquiriesCatalogDto })
getCatalog(): ClientExternalInquiriesCatalogDto {
return this.clientService.getExternalInquiriesCatalog();
}
@Get("external-inquiries")
@ApiOperation({
summary: "List external inquiry settings for all insurers",
description:
"No auth (temporary). Returns stored flags and effective live flags (after global master switch).",
})
@ApiResponse({ status: 200, type: ClientExternalInquiriesListDto })
listAll(): Promise<ClientExternalInquiriesListDto> {
return this.clientService.listExternalInquirySettings();
}
@Get(":clientId/external-inquiries")
@ApiOperation({
summary: "Get external inquiry settings for one insurer",
description: "No auth (temporary).",
})
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
getOne(
@Param("clientId") clientId: string,
): Promise<ClientExternalInquiriesViewDto> {
return this.clientService.getExternalInquirySettings(clientId);
}
@Put(":clientId/external-inquiries")
@ApiOperation({
summary: "Replace external inquiry flags for one insurer",
description:
"No auth (temporary). Omitted inquiry keys default to `false` (mock). Global master switch still applies at runtime.",
})
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
@ApiBody({ type: ExternalInquiryFlagsDto })
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
replace(
@Param("clientId") clientId: string,
@Body() body: ExternalInquiryFlagsDto,
): Promise<ClientExternalInquiriesViewDto> {
return this.clientService.replaceExternalInquirySettings(clientId, body);
}
@Patch(":clientId/external-inquiries")
@ApiOperation({
summary: "Partially update external inquiry flags for one insurer",
description: "No auth (temporary). Only supplied flags are changed.",
})
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
@ApiBody({ type: UpdateClientExternalInquiriesDto })
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
patch(
@Param("clientId") clientId: string,
@Body() body: UpdateClientExternalInquiriesDto,
): Promise<ClientExternalInquiriesViewDto> {
return this.clientService.patchExternalInquirySettings(clientId, body);
}
}

View File

@@ -1,11 +1,20 @@
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
import { import {
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto"; import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
import { SystemSettingsService } from "src/system-settings/system-settings.service"; import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
@@ -21,26 +30,35 @@ export class ClientController {
) {} ) {}
@Post() @Post()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Create a new insurer client (super-admin only)" })
async addClient(@Body() client: ClientDto) { async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client); return await this.clientService.addClient(client);
} }
@Get() @Get()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClient(@CurrentUser() user) { async getClient(@CurrentUser() user) {
return await this.clientService.getClients(); return await this.clientService.getClients();
} }
@Get("list") @Get("list")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClientList(@CurrentUser() user) { async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList(); return await this.clientService.getClientList();
} }
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */ /** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live") @Patch("external-inquiries-live")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: "Enable or disable live external inquiries", summary: "Enable or disable live external inquiries (super-admin only)",
description: description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.", "Updates `system_settings.externalApis.sandHubUseLiveApi`. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
}) })
@ApiBody({ @ApiBody({
type: SetExternalInquiriesLiveDto, type: SetExternalInquiriesLiveDto,
@@ -52,7 +70,8 @@ export class ClientController {
}, },
disableLive: { disableLive: {
summary: "Disable live inquiries (mock mode)", summary: "Disable live inquiries (mock mode)",
description: "Use mocked inquiry responses; flows continue without external connectivity.", description:
"Use mocked inquiry responses; flows continue without external connectivity.",
value: { enabled: false }, value: { enabled: false },
}, },
}, },

View File

@@ -2,8 +2,10 @@ import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { ClientController } from "./client.controller"; import { ClientController } from "./client.controller";
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
import { ClientPanelController } from "./client-panel.controller"; import { ClientPanelController } from "./client-panel.controller";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { BranchDbService } from "./entities/db-service/branch.db.service"; import { BranchDbService } from "./entities/db-service/branch.db.service";
import { ClientDbService } from "./entities/db-service/client.db.service"; import { ClientDbService } from "./entities/db-service/client.db.service";
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema"; import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
@@ -20,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
}, },
]), ]),
], ],
controllers: [ClientController, ClientPanelController], controllers: [
providers: [ClientService, ClientDbService, BranchDbService], ClientController,
exports: [ClientService, ClientDbService, BranchDbService], ClientPanelController,
ClientExternalInquiriesController,
],
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
}) })
export class ClientModule {} export class ClientModule {}

View File

@@ -17,6 +17,19 @@ import {
} from "src/client/dto/client-settings.dto"; } from "src/client/dto/client-settings.dto";
import type { ClientMediaLimits } from "./entities/schema/client.schema"; import type { ClientMediaLimits } from "./entities/schema/client.schema";
import { ClientDbService } from "./entities/db-service/client.db.service"; import { ClientDbService } from "./entities/db-service/client.db.service";
import {
ClientExternalInquiriesCatalogDto,
ClientExternalInquiriesListDto,
ClientExternalInquiriesViewDto,
toClientExternalInquiriesView,
UpdateClientExternalInquiriesDto,
} from "./dto/client-external-inquiries.dto";
import {
EXTERNAL_INQUIRY_TYPES,
mergeExternalInquiryFlags,
} from "src/common/types/external-inquiry.types";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
/** /**
* System-wide default applied when a client document has no * System-wide default applied when a client document has no
@@ -94,9 +107,14 @@ const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
@Injectable() @Injectable()
export class ClientService { export class ClientService {
constructor(private readonly clientDbService: ClientDbService) {} constructor(
private readonly clientDbService: ClientDbService,
private readonly externalInquirySettingsService: ExternalInquirySettingsService,
private readonly systemSettingsService: SystemSettingsService,
) {}
async addClient(client: ClientDto): Promise<ClientDtoRs> { async addClient(client: ClientDto): Promise<ClientDtoRs> {
try { try {
const smsApiKey = client.property?.smsApiKey?.trim();
const newClient = await this.clientDbService.create({ const newClient = await this.clientDbService.create({
clientCode: client.clientCode, clientCode: client.clientCode,
@@ -112,9 +130,7 @@ export class ClientService {
: (client.clientName?.english ?? null), : (client.clientName?.english ?? null),
}, },
property: { ...(smsApiKey ? { property: { smsApiKey } } : {}),
smsApiKey: client.property?.smsApiKey ?? null,
},
useExpertMode: client.useExpertMode ?? null, useExpertMode: client.useExpertMode ?? null,
}); });
@@ -393,4 +409,74 @@ export class ClientService {
return this.getPanelSettings(client._id); return this.getPanelSettings(client._id);
} }
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
return {
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
perClientSettingPath: "/client/{clientId}/external-inquiries",
};
}
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
const global = await this.systemSettingsService.isSandHubLiveEnabled();
const clients = await this.clientDbService.findAll();
return {
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
};
}
async getExternalInquirySettings(
clientKey: string | Types.ObjectId,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const global = await this.systemSettingsService.isSandHubLiveEnabled();
return toClientExternalInquiriesView(client, global);
}
async replaceExternalInquirySettings(
clientKey: string | Types.ObjectId,
body: UpdateClientExternalInquiriesDto,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
const $set: Record<string, unknown> = {};
for (const key of EXTERNAL_INQUIRY_TYPES) {
$set[`settings.externalInquiries.${key}`] = flags[key];
}
const updated = await this.clientDbService.findByIdAndUpdate(
client._id.toString(),
{ $set },
);
if (!updated) throw new NotFoundException("Client not found");
this.externalInquirySettingsService.invalidateClientCache(
client._id.toString(),
);
return this.getExternalInquirySettings(client._id);
}
async patchExternalInquirySettings(
clientKey: string | Types.ObjectId,
body: UpdateClientExternalInquiriesDto,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const $set: Record<string, unknown> = {};
for (const key of EXTERNAL_INQUIRY_TYPES) {
if (body[key] !== undefined) {
$set[`settings.externalInquiries.${key}`] = body[key];
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No external inquiry flags to update.");
}
const updated = await this.clientDbService.findByIdAndUpdate(
client._id.toString(),
{ $set },
);
if (!updated) throw new NotFoundException("Client not found");
this.externalInquirySettingsService.invalidateClientCache(
client._id.toString(),
);
return this.getExternalInquirySettings(client._id);
}
} }

View File

@@ -0,0 +1,162 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import {
EXTERNAL_INQUIRY_TYPES,
ExternalInquiryFlags,
mergeExternalInquiryFlags,
} from "src/common/types/external-inquiry.types";
import { IsBoolean, IsMongoId, IsOptional, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
@ApiProperty({
description: "Third-party plate / policy block inquiry (Tejarat or ESG policyByPlate).",
example: false,
})
@IsBoolean()
thirdPartyPlate: boolean;
@ApiProperty({
description: "CAR_BODY (badane) plate inquiry.",
example: false,
})
@IsBoolean()
carBodyPlate: boolean;
@ApiProperty({
description: "Personal identity inquiry (national code + birth date).",
example: false,
})
@IsBoolean()
personalIdentity: boolean;
@ApiProperty({
description: "Sheba account validation.",
example: false,
})
@IsBoolean()
sheba: boolean;
@ApiProperty({
description: "Driving licence check.",
example: false,
})
@IsBoolean()
drivingLicense: boolean;
@ApiProperty({
description: "Vehicle ownership validation.",
example: false,
})
@IsBoolean()
carOwnership: boolean;
@ApiProperty({
description:
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
example: false,
})
@IsBoolean()
vinChassis: boolean;
}
export class UpdateClientExternalInquiriesDto extends PartialType(
ExternalInquiryFlagsDto,
) {}
export class ClientExternalInquiriesViewDto {
@ApiProperty({ example: "664a1b2c3d4e5f6789012345" })
clientId: string;
@ApiProperty({ example: 8 })
clientCode: number;
@ApiProperty({ example: "بیمه پارسیان" })
clientName: string;
@ApiProperty({
description:
"Global master switch from `system_settings.externalApis.sandHubUseLiveApi`. When false, all inquiries use mocks regardless of these flags.",
})
globalSandHubLiveEnabled: boolean;
@ApiProperty({ type: ExternalInquiryFlagsDto })
externalInquiries: ExternalInquiryFlags;
@ApiProperty({
description: "Effective live flags after applying the global master switch.",
type: ExternalInquiryFlagsDto,
})
effectiveLive: ExternalInquiryFlags;
}
export class ClientExternalInquiriesListDto {
@ApiProperty({ type: [ClientExternalInquiriesViewDto] })
items: ClientExternalInquiriesViewDto[];
}
export class ClientExternalInquiriesCatalogDto {
@ApiProperty({
enum: EXTERNAL_INQUIRY_TYPES,
isArray: true,
description: "Supported inquiry kinds stored on each client document.",
})
inquiryTypes: readonly string[];
@ApiProperty({
description:
"Global master switch path: PATCH /system-settings or PATCH /client/external-inquiries-live",
})
globalSettingPath: string;
@ApiProperty({
description: "Per-insurer CRUD base path (public for now).",
example: "/client/{clientId}/external-inquiries",
})
perClientSettingPath: string;
}
/** Build a view DTO from a lean client document. */
export function toClientExternalInquiriesView(
client: {
_id?: unknown;
clientCode?: number;
clientName?: { persian?: string; english?: string } | string;
settings?: { externalInquiries?: Partial<ExternalInquiryFlags> };
},
globalSandHubLiveEnabled: boolean,
): ClientExternalInquiriesViewDto {
const flags = mergeExternalInquiryFlags(client.settings?.externalInquiries);
const effectiveLive = {} as ExternalInquiryFlags;
for (const key of EXTERNAL_INQUIRY_TYPES) {
effectiveLive[key] = globalSandHubLiveEnabled && flags[key] === true;
}
const name =
typeof client.clientName === "string"
? client.clientName
: client.clientName?.persian ||
client.clientName?.english ||
String(client.clientCode ?? "");
return {
clientId: String(client._id ?? ""),
clientCode: Number(client.clientCode ?? 0),
clientName: name,
globalSandHubLiveEnabled,
externalInquiries: flags,
effectiveLive,
};
}
export class ClientIdParamDto {
@ApiProperty({ description: "Mongo ObjectId of the insurer client document" })
@IsMongoId()
clientId: string;
}
/** Optional nested patch used internally when validating partial bodies. */
export class ExternalInquiryFlagsPatchDto {
@IsOptional()
@ValidateNested()
@Type(() => UpdateClientExternalInquiriesDto)
externalInquiries?: UpdateClientExternalInquiriesDto;
}

View File

@@ -23,8 +23,9 @@ class ClientName {
} }
class Property { class Property {
@ApiPropertyOptional({}) @ApiPropertyOptional({})
@IsOptional()
@IsString() @IsString()
smsApiKey: string; smsApiKey?: string;
} }
export class ClientDto { export class ClientDto {

View File

@@ -1,15 +1,49 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model, UpdateQuery } from "mongoose"; import { FilterQuery, Model, UpdateQuery } from "mongoose";
import { ClientModel, ClientDocument } from "../schema/client.schema"; import { ClientModel, ClientDocument } from "../schema/client.schema";
@Injectable() @Injectable()
export class ClientDbService { export class ClientDbService implements OnModuleInit {
private readonly logger = new Logger(ClientDbService.name);
constructor( constructor(
@InjectModel(ClientModel.name) @InjectModel(ClientModel.name)
private readonly clientModel: Model<ClientModel>, private readonly clientModel: Model<ClientModel>,
) {} ) {}
async onModuleInit(): Promise<void> {
await this.dropLegacyPropertyUniqueIndex();
}
/**
* `property` was once declared `unique: true` in the schema. Production keeps
* `autoIndex: false`, so the stale unique index remained and rejected a second
* client with a missing/null SMS key (E11000 duplicate key).
*/
private async dropLegacyPropertyUniqueIndex(): Promise<void> {
try {
const indexes = await this.clientModel.collection.indexes();
for (const index of indexes) {
if (!index.unique || !index.name || index.name === "_id_") continue;
const keys = Object.keys(index.key ?? {});
const isLegacyPropertyIndex =
keys.length === 1 &&
(keys[0] === "property" || keys[0] === "property.smsApiKey");
if (!isLegacyPropertyIndex) continue;
await this.clientModel.collection.dropIndex(index.name);
this.logger.warn(
`Dropped legacy unique index on clients.${keys[0]}: ${index.name}`,
);
}
} catch (err) {
this.logger.error("Failed to drop legacy client property index", err);
}
}
async create(client: ClientModel): Promise<ClientModel> { async create(client: ClientModel): Promise<ClientModel> {
return await this.clientModel.create(client); return await this.clientModel.create(client);
} }

View File

@@ -1,4 +1,5 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
export type ClientDocument = ClientModel & Document; export type ClientDocument = ClientModel & Document;
@@ -37,6 +38,30 @@ export class ClientMediaSettings {
voice?: ClientMediaLimits; voice?: ClientMediaLimits;
} }
/**
* Per-insurer toggles for outbound inquiry HTTP. Each flag is AND-ed with the
* global `system_settings.externalApis.sandHubUseLiveApi` master switch.
*/
export class ClientExternalInquirySettings implements Partial<ExternalInquiryFlags> {
@Prop({ type: Boolean, required: false, default: false })
thirdPartyPlate?: boolean;
@Prop({ type: Boolean, required: false, default: false })
carBodyPlate?: boolean;
@Prop({ type: Boolean, required: false, default: false })
personalIdentity?: boolean;
@Prop({ type: Boolean, required: false, default: false })
sheba?: boolean;
@Prop({ type: Boolean, required: false, default: false })
drivingLicense?: boolean;
@Prop({ type: Boolean, required: false, default: false })
carOwnership?: boolean;
}
/** /**
* Per-tenant tunables. Add new policy fields here; consumers read them via * Per-tenant tunables. Add new policy fields here; consumers read them via
* `ClientService` with documented defaults so older client documents keep * `ClientService` with documented defaults so older client documents keep
@@ -57,6 +82,10 @@ export class ClientSettings {
*/ */
@Prop({ type: ClientMediaSettings, required: false }) @Prop({ type: ClientMediaSettings, required: false })
media?: ClientMediaSettings; media?: ClientMediaSettings;
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
@Prop({ type: ClientExternalInquirySettings, required: false })
externalInquiries?: ClientExternalInquirySettings;
} }
@Schema({ collection: "clients", versionKey: false }) @Schema({ collection: "clients", versionKey: false })
@@ -67,9 +96,9 @@ export class ClientModel {
english: string; english: string;
}; };
@Prop({ required: false, unique: false, type: Object }) @Prop({ required: false, type: Object })
property: { property?: {
smsApiKey: string; smsApiKey?: string;
}; };
@Prop({ required: true, unique: false }) @Prop({ required: true, unique: false })

View File

@@ -0,0 +1,83 @@
import { Types } from "mongoose";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
describe("ExternalInquirySettingsService", () => {
const systemSettings = {
isSandHubLiveEnabled: jest.fn(),
};
const clientDb = {
findOne: jest.fn(),
};
let service: ExternalInquirySettingsService;
beforeEach(() => {
jest.clearAllMocks();
delete process.env.CLIENT_ID;
delete process.env.CLIENT_NAME;
service = new ExternalInquirySettingsService(
systemSettings as unknown as SystemSettingsService,
clientDb as any,
);
service.invalidateClientCache();
});
it("returns false when global master switch is off", async () => {
systemSettings.isSandHubLiveEnabled.mockResolvedValue(false);
clientDb.findOne.mockResolvedValue({
clientCode: 8,
settings: { externalInquiries: { sheba: true } },
});
await expect(service.isInquiryLive("sheba", "client-id")).resolves.toBe(
false,
);
});
it("returns true only when global and per-insurer flags are on", async () => {
const clientId = new Types.ObjectId().toString();
systemSettings.isSandHubLiveEnabled.mockResolvedValue(true);
clientDb.findOne.mockResolvedValue({
clientCode: 8,
clientName: { persian: "بیمه پارسیان" },
settings: {
externalInquiries: {
thirdPartyPlate: true,
sheba: false,
},
},
});
await expect(
service.isInquiryLive("thirdPartyPlate", clientId),
).resolves.toBe(true);
await expect(service.isInquiryLive("sheba", clientId)).resolves.toBe(
false,
);
});
it("uses client document for mock company context", async () => {
const clientId = new Types.ObjectId().toString();
clientDb.findOne.mockResolvedValue({
clientCode: 8,
clientName: { persian: "بیمه پارسیان" },
});
await expect(service.getMockCompanyContext(clientId)).resolves.toEqual({
companyId: "8",
companyName: "بیمه پارسیان",
});
});
it("falls back to deployment env when client is missing", async () => {
process.env.CLIENT_ID = "15";
process.env.CLIENT_NAME = "بیمه سامان";
clientDb.findOne.mockResolvedValue(null);
await expect(service.getMockCompanyContext()).resolves.toEqual({
companyId: "15",
companyName: "بیمه سامان",
});
});
});

View File

@@ -0,0 +1,120 @@
import { Injectable, Logger } from "@nestjs/common";
import { Types } from "mongoose";
import {
DEFAULT_EXTERNAL_INQUIRY_FLAGS,
ExternalInquiryFlags,
ExternalInquiryType,
mergeExternalInquiryFlags,
MockInquiryCompanyContext,
} from "src/common/types/external-inquiry.types";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { ClientDbService } from "./entities/db-service/client.db.service";
@Injectable()
export class ExternalInquirySettingsService {
private readonly logger = new Logger(ExternalInquirySettingsService.name);
private clientCache = new Map<string, { doc: any; at: number }>();
private readonly cacheTtlMs = 15_000;
constructor(
private readonly systemSettingsService: SystemSettingsService,
private readonly clientDbService: ClientDbService,
) {}
private cacheKey(ref?: string | Types.ObjectId | null): string {
if (ref != null && String(ref).trim()) return `id:${String(ref)}`;
const code = process.env.CLIENT_ID;
return code ? `env:${code}` : "env:default";
}
private async loadClient(
clientRef?: string | Types.ObjectId | null,
): Promise<any | null> {
const key = this.cacheKey(clientRef);
const cached = this.clientCache.get(key);
const now = Date.now();
if (cached && now - cached.at < this.cacheTtlMs) {
return cached.doc;
}
let doc: any = null;
if (clientRef != null && Types.ObjectId.isValid(String(clientRef))) {
doc = await this.clientDbService.findOne({
_id: new Types.ObjectId(String(clientRef)),
});
}
if (!doc) {
const code = Number(process.env.CLIENT_ID);
if (Number.isFinite(code)) {
doc = await this.clientDbService.findOne({ clientCode: code });
}
}
this.clientCache.set(key, { doc, at: now });
return doc;
}
invalidateClientCache(clientId?: string): void {
if (clientId) {
this.clientCache.delete(`id:${clientId}`);
} else {
this.clientCache.clear();
}
}
async getFlagsForClient(
clientRef?: string | Types.ObjectId | null,
): Promise<ExternalInquiryFlags> {
const client = await this.loadClient(clientRef);
return mergeExternalInquiryFlags(client?.settings?.externalInquiries);
}
/**
* Live HTTP when global master switch is on AND the per-insurer flag is true.
*/
async isInquiryLive(
type: ExternalInquiryType,
clientRef?: string | Types.ObjectId | null,
): Promise<boolean> {
if (!(await this.systemSettingsService.isSandHubLiveEnabled())) {
return false;
}
const flags = await this.getFlagsForClient(clientRef);
return flags[type] === true;
}
/** Company fields injected into mocked plate/car-body inquiry payloads. */
async getMockCompanyContext(
clientRef?: string | Types.ObjectId | null,
): Promise<MockInquiryCompanyContext> {
const client = await this.loadClient(clientRef);
if (client) {
const name =
typeof client.clientName === "string"
? client.clientName
: client.clientName?.persian ||
client.clientName?.english ||
"";
return {
companyId: String(client.clientCode ?? process.env.CLIENT_ID ?? "15"),
companyName: name || process.env.CLIENT_NAME || "بیمه",
};
}
return {
companyId: String(process.env.CLIENT_ID ?? "15"),
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
};
}
async getEffectiveLiveFlags(
clientRef?: string | Types.ObjectId | null,
): Promise<ExternalInquiryFlags> {
const global = await this.systemSettingsService.isSandHubLiveEnabled();
const flags = await this.getFlagsForClient(clientRef);
const effective = { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS };
for (const key of Object.keys(effective) as ExternalInquiryType[]) {
effective[key] = global && flags[key] === true;
}
return effective;
}
}

View File

@@ -0,0 +1,18 @@
import { ScryptOptions } from "node:crypto";
export const CURRENT_ALGORITHM = "scrypt";
export const CURRENT_VERSION = 1; // 0 = legacy salt:hash ; 1 = scrypt with different salt and hash and differnet format of salt:hash!
export const KEY_LENGTH = 64;
export const SCRYPT_PARAMS: ScryptOptions = {
N: 19456, // CPU/memory cost
r: 8, // block size
p: 1, // parallelization
};
export const KEY_LENGTH_FOR_OTP = 32;
export const SCRYPT_PARAMS_FOR_OTP: ScryptOptions = {
N: 1024,
r: 8,
p: 1,
};

View File

@@ -0,0 +1,8 @@
export {
CURRENT_ALGORITHM,
CURRENT_VERSION,
KEY_LENGTH,
KEY_LENGTH_FOR_OTP,
SCRYPT_PARAMS,
SCRYPT_PARAMS_FOR_OTP,
} from "./hash.constants";

View File

@@ -0,0 +1,2 @@
export { IS_PUBLIC_KEY, Public } from "./public.decorator";
export { ROLES_KEY, Roles } from "./roles.decorator";

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";
import { Role } from "../enums/roles.enum";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1 @@
export { Role } from "./roles.enum";

View File

@@ -0,0 +1,9 @@
export enum Role {
SUPER_ADMIN = "super_admin",
INSURER = "insurer",
ACCIDENT_EXPERT = "accident_expert",
CLAIM_EXPERT = "claim_expert",
FIELD_EXPERT = "field_expert",
REGISTRAR = "registrar",
USER = "user",
}

View File

@@ -0,0 +1,37 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { JwtPayload } from "../types/payload.types";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException("No token provided");
}
try {
const payload: JwtPayload =
await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: `${process.env.JWT_SECRET}`,
});
request["user"] = payload;
} catch {
throw new UnauthorizedException("Invalid or expired token");
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined;
}
}

View File

@@ -0,0 +1,2 @@
export { AuthGuard } from "./auth.guard";
export { RolesGuard } from "./role.guard";

View File

@@ -0,0 +1,47 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { IS_PUBLIC_KEY, ROLES_KEY } from "../decorators";
import { Role } from "../enums";
import { JwtPayload } from "../types";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const request = context.switchToHttp().getRequest<Request>();
const user: JwtPayload = request["user"] as JwtPayload | undefined;
if (!user?.role) {
throw new ForbiddenException("No role found in token");
}
const hasRole = requiredRoles.includes(user.role as Role);
if (!hasRole) {
throw new ForbiddenException(
`Access denied. Required: [${requiredRoles.join(", ")}]. Your role: ${user.role}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,2 @@
export { JwtPayload } from "./payload.types";
export { PasswordHash } from "./password.types";

View File

@@ -0,0 +1,8 @@
export interface PasswordHash {
hash: string;
salt: string;
algorithm: string;
version: number;
params: Record<string, unknown>;
createdAt: Date;
}

View File

@@ -0,0 +1,6 @@
export interface JwtPayload {
sub: string;
role: string;
iat?: number;
exp?: number;
}

View File

@@ -3,14 +3,17 @@ import { Type } from "class-transformer";
import { import {
IsIn, IsIn,
IsInt, IsInt,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
Max, Max,
MaxLength, MaxLength,
Min, Min,
} from "class-validator"; } from "class-validator";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { UNIFIED_FILE_STATUS_KEYS } from "src/helpers/unified-file-status";
/** Allowed sort keys for V2 list endpoints (claims / blame). */ /** Allowed sort keys for V2 list endpoints (claims / blame / insurer files). */
export const LIST_SORT_BY_V2 = [ export const LIST_SORT_BY_V2 = [
"publicId", "publicId",
"createdAt", "createdAt",
@@ -20,9 +23,16 @@ export const LIST_SORT_BY_V2 = [
export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number]; export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number];
export const LIST_FILE_TYPE_V2 = [
BlameRequestType.THIRD_PARTY,
BlameRequestType.CAR_BODY,
] as const;
export type ListFileTypeV2 = (typeof LIST_FILE_TYPE_V2)[number];
/** /**
* Optional query params for V2 lists. If neither `page` nor `limit` is sent, * Optional query params for V2 expert/insurer file lists.
* the full filtered+sorted list is returned (backward compatible). * If neither `page` nor `limit` is sent, the full filtered+sorted list is returned.
*/ */
export class ListQueryV2Dto { export class ListQueryV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -67,7 +77,7 @@ export class ListQueryV2Dto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).", "Case-insensitive substring match on `publicId`, `requestNo`, status, and other endpoint-specific fields.",
example: "A142", example: "A142",
maxLength: 64, maxLength: 64,
}) })
@@ -75,4 +85,38 @@ export class ListQueryV2Dto {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
search?: string; search?: string;
@ApiPropertyOptional({
enum: UNIFIED_FILE_STATUS_KEYS,
description:
"Filter by calculated unified file status (blame + claim combined).",
})
@IsOptional()
@IsIn([...UNIFIED_FILE_STATUS_KEYS])
unifiedStatus?: (typeof UNIFIED_FILE_STATUS_KEYS)[number];
@ApiPropertyOptional({
enum: LIST_FILE_TYPE_V2,
description:
"Filter by blame file type: third-party liability (`THIRD_PARTY`) or car body (`CAR_BODY`).",
})
@IsOptional()
@IsIn([...LIST_FILE_TYPE_V2])
fileType?: ListFileTypeV2;
@ApiPropertyOptional({
description: "Filter start date (ISO 8601). Only files created on or after this date are returned.",
example: "2025-01-01T00:00:00.000Z",
})
@IsOptional()
@IsISO8601({ strict: false })
startDate?: string;
@ApiPropertyOptional({
description: "Filter end date (ISO 8601). Only files created on or before this date are returned.",
example: "2025-12-31T23:59:59.999Z",
})
@IsOptional()
@IsISO8601({ strict: false })
endDate?: string;
} }

View File

@@ -0,0 +1,68 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsISO8601, IsOptional } from "class-validator";
import {
LIST_FILE_TYPE_V2,
ListFileTypeV2,
} from "src/common/dto/list-query-v2.dto";
import {
UNIFIED_FILE_STATUS_KEYS,
UnifiedFileStatusDefinition,
UnifiedFileStatusKey,
} from "src/helpers/unified-file-status";
export class UnifiedFileStatusReportQueryDto {
@ApiPropertyOptional({
description: "Optional start datetime (ISO 8601) for portfolio date filter.",
example: "2026-01-01T00:00:00.000Z",
})
@IsOptional()
@IsISO8601({ strict: false })
from?: string;
@ApiPropertyOptional({
description: "Optional end datetime (ISO 8601) for portfolio date filter.",
example: "2026-12-31T23:59:59.999Z",
})
@IsOptional()
@IsISO8601({ strict: false })
to?: string;
@ApiPropertyOptional({
enum: LIST_FILE_TYPE_V2,
description:
"Count only files of this blame type (`THIRD_PARTY` or `CAR_BODY`).",
})
@IsOptional()
@IsIn([...LIST_FILE_TYPE_V2])
fileType?: ListFileTypeV2;
}
export class UnifiedFileStatusDefinitionDto implements UnifiedFileStatusDefinition {
@ApiProperty({ enum: UNIFIED_FILE_STATUS_KEYS })
key: UnifiedFileStatusKey;
@ApiProperty()
labelEn: string;
@ApiProperty()
labelFa: string;
@ApiProperty()
description: string;
}
export class UnifiedFileStatusReportDto {
@ApiProperty({ type: [UnifiedFileStatusDefinitionDto] })
statuses: UnifiedFileStatusDefinitionDto[];
@ApiProperty({
description: "Counts per unified status key plus `all`",
example: {
all: 42,
IN_PROGRESS: 10,
WAITING_FOR_DAMAGE_EXPERT: 5,
COMPLETED: 12,
},
})
counts: Record<string, number>;
}

View File

@@ -0,0 +1,39 @@
/** External inquiry kinds toggled per insurer (`clients.settings.externalInquiries`). */
export const EXTERNAL_INQUIRY_TYPES = [
"thirdPartyPlate",
"carBodyPlate",
"personalIdentity",
"sheba",
"drivingLicense",
"carOwnership",
"vinChassis",
] as const;
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
/** Safe default: mock/off until explicitly enabled for an insurer. */
export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
ExternalInquiryType,
boolean
> = {
thirdPartyPlate: false,
carBodyPlate: false,
personalIdentity: false,
sheba: false,
drivingLicense: false,
carOwnership: false,
vinChassis: false,
};
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;
export interface MockInquiryCompanyContext {
companyId: string;
companyName: string;
}
export function mergeExternalInquiryFlags(
partial?: Partial<ExternalInquiryFlags> | null,
): ExternalInquiryFlags {
return { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS, ...(partial ?? {}) };
}

View File

@@ -10,3 +10,27 @@ export const REPAIR_LINE_AMOUNT_TOMAN = {
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ /** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
* Returns null when the claim v2 total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
*/
export function getClaimV2TotalPaymentCapToman(): number | null {
const capEnabled = ENABLED_VALUES.has(
String(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED ?? "")
.trim()
.toLowerCase(),
);
if (!capEnabled) return null;
const configuredCap = Number(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN);
return Number.isFinite(configuredCap) && configuredCap > 0
? configuredCap
: CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
}

View File

@@ -2,6 +2,7 @@ import {
IsBooleanString, IsBooleanString,
IsEnum, IsEnum,
IsNumber, IsNumber,
IsOptional,
IsPositive, IsPositive,
IsString, IsString,
IsUrl, IsUrl,
@@ -22,7 +23,9 @@ export class EnvironmentVariables {
message: "NODE_ENV should be development or production", message: "NODE_ENV should be development or production",
}) })
NODE_ENV: Environment; NODE_ENV: Environment;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsNumber( @IsNumber(
{ {
allowNaN: false, allowNaN: false,
@@ -36,7 +39,9 @@ export class EnvironmentVariables {
@Min(0, { message: "PORT must be between 0 and 65535" }) @Min(0, { message: "PORT must be between 0 and 65535" })
@Max(65535, { message: "PORT must be between 0 and 65535" }) @Max(65535, { message: "PORT must be between 0 and 65535" })
PORT: number; PORT: number;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsString({ message: "MONGO_URI must be string" }) @IsString({ message: "MONGO_URI must be string" })
@IsUrl( @IsUrl(
{ {
@@ -48,28 +53,59 @@ export class EnvironmentVariables {
}, },
) )
MONGO_URI: string; MONGO_URI: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsBooleanString({ message: "MONGO_TLS must be boolean" }) @IsBooleanString({ message: "MONGO_TLS must be boolean" })
MONGO_TLS: string; MONGO_TLS: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsBooleanString({ message: "MONGO_TLS_ALLOW_INVALID_CERTS must be boolean" }) @IsBooleanString({ message: "MONGO_TLS_ALLOW_INVALID_CERTS must be boolean" })
MONGO_TLS_ALLOW_INVALID_CERTS: string; MONGO_TLS_ALLOW_INVALID_CERTS: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsString({ message: "JWT_SECRET must be string" }) @IsString({ message: "JWT_SECRET must be string" })
@MinLength(32, { @MinLength(32, {
message: "JWT_SECRET must be at least 32 characters", message: "JWT_SECRET must be at least 32 characters",
}) })
JWT_SECRET: string; JWT_SECRET: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@Matches(/^\d+(ms|s|m|h|d|w|y)$/, { @Matches(/^\d+(ms|s|m|h|d|w|y)$/, {
message: "JWT_EXPIRY must be in the correct format", message: "JWT_EXPIRY must be in the correct format",
}) })
JWT_EXPIRY: StringValue; JWT_EXPIRY: StringValue;
// --------------------------------------------------------- // // --------------------------------------------------------- //
// @IsString({ message: "HASH_PEPPER must be string" })
// @MinLength(32, { @IsString({ message: "HASH_PEPPER must be string" })
// message: "HASH_PEPPER must be at least 32 characters", @IsOptional()
// }) HASH_PEPPER?: string;
// HASH_PEPPER: string;
// --------------------------------------------------------- //
@IsOptional()
@IsString({ message: "CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED must be string" })
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED?: string;
// --------------------------------------------------------- //
@IsOptional()
@IsNumber(
{
allowNaN: false,
allowInfinity: false,
},
{
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a valid number",
},
)
@IsPositive({
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a positive number",
})
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN?: number;
// --------------------------------------------------------- // // --------------------------------------------------------- //
} }

View File

@@ -0,0 +1,168 @@
export type FanavaranClientKey = "parsian" | "tejaratno";
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
"parsian",
"tejaratno",
] as const;
export function isFanavaranClientKey(
value: string,
): value is FanavaranClientKey {
const normalized = value?.trim().toLowerCase();
return normalized === "parsian" || normalized === "tejaratno";
}
export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
const normalized = value?.trim().toLowerCase();
if (normalized === "parsian" || normalized === "tejaratno") {
return normalized;
}
throw new Error(
`Invalid Fanavaran client "${value}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`,
);
}
export interface FanavaranAuthConfig {
appName: string;
secret: string;
username: string;
password: string;
corpId: string;
contractId: string;
location: string;
}
export interface FanavaranPayloadDefaults {
AccidentCityId: number;
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
ExpertiseClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
DmgCaseTypeId: number;
DmgHistoryStatus: number;
PlaqueKindId: number;
PlaqueSampleId: number;
DriverIsOwner: number;
FaultPercent: number;
ClaimFileTypeId: number;
}
export interface FanavaranClientProfile {
key: FanavaranClientKey;
auth: FanavaranAuthConfig;
defaults: FanavaranPayloadDefaults;
}
const FANAVARAN_CLIENT_PROFILES: Record<
FanavaranClientKey,
FanavaranClientProfile
> = {
tejaratno: {
key: "tejaratno",
auth: {
appName: "fanhab",
secret: "5Fa@N#A2B",
username: "fanhabUser",
password: "Fan#@2U$3er",
corpId: "3539",
contractId: "263",
location: "100",
},
defaults: {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 4543092,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23,
},
},
parsian: {
key: "parsian",
auth: {
appName: "ParsianService",
secret: "P@r30@n$erv!ce",
username: "ParsianServiceUser",
password: "P@r30@n123",
corpId: "543",
contractId: "28",
location: "210050",
},
defaults: {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 154,
ExpertiseClaimExpertId: 29,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 70,
},
},
};
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
export function resolveFanavaranClientKey(): FanavaranClientKey {
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
if (explicit === "parsian" || explicit === "tejaratno") {
return explicit;
}
// Optional deployment hint when FANAVARAN_CLIENT is not set.
if (String(process.env.CLIENT_ID ?? "") === "8") {
return "parsian";
}
return "tejaratno";
}
export function resolveFanavaranClientProfile(): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
}
export function getFanavaranClientProfile(
clientKey: FanavaranClientKey,
): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[clientKey];
}
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
return FANAVARAN_CLIENT_KEYS.map((key) => FANAVARAN_CLIENT_PROFILES[key]);
}
export function fanavaranPreviewPath(
clientKey: FanavaranClientKey,
claimCaseId: string,
): string {
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}/base-claim/preview`;
}
export function fanavaranSubmitPath(
clientKey: FanavaranClientKey,
claimCaseId: string,
): string {
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}/base-claim/submit`;
}
export function fanavaranManualSubmitPath(claimCaseId: string): string {
return fanavaranSubmitPath(resolveFanavaranClientKey(), claimCaseId);
}

View File

@@ -0,0 +1,26 @@
import { ConfigService } from "@nestjs/config";
import { HttpModuleOptions } from "@nestjs/axios";
import { SocksProxyAgent } from "socks-proxy-agent";
/**
* Shared HttpModule.registerAsync factory that applies the SOCKS proxy
* when SOCKS_PROXY_HOST + SOCKS_PROXY_PORT env vars are set.
* Used by every module that imports HttpModule.
*/
export function createHttpModuleOptions(
configService: ConfigService,
): HttpModuleOptions | Promise<HttpModuleOptions> {
const socksHost = configService.get<string>("SOCKS_PROXY_HOST");
const socksPort = configService.get<string>("SOCKS_PROXY_PORT");
if (socksHost && socksPort) {
const proxyUrl = `socks5://${socksHost}:${socksPort}`;
const agent = new SocksProxyAgent(proxyUrl);
return {
httpsAgent: agent,
httpAgent: agent,
proxy: false,
};
}
return {};
}

View File

@@ -117,8 +117,16 @@ export class AllRequestDtoV2 {
lockTime: string | null; lockTime: string | null;
type: string; type: string;
blameStatus: string; blameStatus: string;
/** Calculated blame + linked claim lifecycle status */
unifiedFileStatus?: string;
partiesInitialForms: { firstParty: string; secondParty: string }; partiesInitialForms: { firstParty: string; secondParty: string };
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string }; partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
/**
* True when the file is in WAITING_FOR_FILE_REVIEWER status — the FileReviewer
* must complete accident fields, capture, and upload-video via v4 endpoints
* before the file enters the normal expert-blame review queue.
*/
needsFileReviewerCompletion?: boolean;
} }
export class AllRequestDtoRsV2 { export class AllRequestDtoRsV2 {

View File

@@ -19,6 +19,7 @@ import {
ApiProduces, ApiProduces,
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiExcludeController,
} 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";
@@ -35,7 +36,8 @@ import {
} from "./dto/reply.dto"; } from "./dto/reply.dto";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
@ApiTags("expert-blame-panel") @ApiExcludeController()
// @ApiTags("expert-blame-panel")
@Controller("expert-blame") @Controller("expert-blame")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@@ -45,35 +47,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 }) // @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 }) // @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 }) // @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 }) // @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 }) // @ApiOperation({ deprecated: true })
@Put("reply/submit/:id") @Put("reply/submit/:id")
@ApiBody({ type: SubmitReplyDto }) @ApiBody({ type: SubmitReplyDto })
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
@@ -100,10 +102,10 @@ export class ExpertBlameController {
} }
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true }) // @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" })
async resendFirstParty( async resendFirstParty(
@Param("id") id: string, @Param("id") id: string,
@Body() body: SendAginIF, @Body() body: SendAginIF,
@@ -113,11 +115,11 @@ 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 }) // @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 })
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
async resendSecondParty( async resendSecondParty(
@Param("id") id: string, @Param("id") id: string,
@Body() body: SendAginIF, @Body() body: SendAginIF,
@@ -127,25 +129,25 @@ 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 }) // @ApiOperation({ deprecated: true })
@Get("stream/:requestId") @Get("stream/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
async streamVideo(@Param("requestId") requestId: string) { async streamVideo(@Param("requestId") requestId: string) {
return this.expertBlameService.streamVideo(requestId); return this.expertBlameService.streamVideo(requestId);
} }
@ApiOperation({ deprecated: true }) // @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" })
@ApiParam({ name: "voiceId" }) // @ApiParam({ name: "voiceId" })
@ApiOkResponse({ // @ApiOkResponse({
schema: { // schema: {
type: "string", // type: "string",
format: "binary", // format: "binary",
}, // },
}) // })
@ApiProduces("mp3") // @ApiProduces("mp3")
async downloadVoice( async downloadVoice(
@Param("voiceId") voiceId, @Param("voiceId") voiceId,
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@@ -156,8 +158,8 @@ export class ExpertBlameController {
return await this.expertBlameService.streamVoice(requestId, voiceId); return await this.expertBlameService.streamVoice(requestId, voiceId);
} }
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true }) // @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

@@ -7,6 +7,8 @@ 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"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { LookupsModule } from "src/lookups/lookups.module";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
@Module({ @Module({
imports: [ imports: [
@@ -15,6 +17,8 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
RequestManagementModule, RequestManagementModule,
PlatesModule, PlatesModule,
SmsOrchestrationModule, SmsOrchestrationModule,
ClaimRequestManagementModule,
LookupsModule,
], ],
controllers: [ExpertBlameController, ExpertBlameV2Controller], controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService], providers: [ExpertBlameService],

View File

@@ -11,6 +11,8 @@ import {
import { import {
assertBlameCaseForExpertTenant, assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert, blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert,
blameCaseTouchesClient,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } from "src/helpers/tenant-scope";
import { import {
@@ -22,8 +24,19 @@ import {
expertPortfolioFileIdsFromActivityEvents, expertPortfolioFileIdsFromActivityEvents,
objectIdsFromStringSet, objectIdsFromStringSet,
} from "src/helpers/expert-portfolio"; } from "src/helpers/expert-portfolio";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import {
countUnifiedFileStatuses,
getUnifiedFileStatusCatalog,
resolveUnifiedFileStatus,
} from "src/helpers/unified-file-status";
import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { LookupsService } from "src/lookups/lookups.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { import {
ExpertFileAssignResultDto, ExpertFileAssignResultDto,
ExpertFileAssignStatus, ExpertFileAssignStatus,
@@ -34,6 +47,10 @@ import {
buildExpertAssignConflictForOtherReviewer, buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId, resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee"; } from "src/helpers/expert-workflow-review-assignee";
import {
EXPERT_WORKFLOW_LOCK_TTL_MS,
expertWorkflowLockExpiredAt,
} from "src/helpers/expert-workflow-lock";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -68,6 +85,7 @@ import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
import { ExpertModel } from "src/users/entities/schema/expert.schema"; import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { import {
ExpertFileActivityType, ExpertFileActivityType,
@@ -98,9 +116,6 @@ function statementToFormKey(statement?: {
export class ExpertBlameService { export class ExpertBlameService {
private readonly logger = new Logger(ExpertBlameService.name); private readonly logger = new Logger(ExpertBlameService.name);
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
constructor( constructor(
private readonly requestManagementDbService: RequestManagementDbService, private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService, private readonly blameRequestDbService: BlameRequestDbService,
@@ -113,8 +128,127 @@ export class ExpertBlameService {
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly lookupsService: LookupsService,
) {} ) {}
private async loadClaimsByPublicIds(
publicIds: string[],
): Promise<Map<string, { status?: string }>> {
const unique = [...new Set(publicIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const claims = (await this.claimCaseDbService.find(
{ publicId: { $in: unique } },
{ lean: true, select: "publicId status" },
)) as Array<{ publicId?: string; status?: string }>;
return new Map(
claims.map((c) => [String(c.publicId), { status: c.status }]),
);
}
private filterBlameDocsByUnifiedStatus(
docs: Record<string, unknown>[],
claimByPublicId: Map<string, { status?: string }>,
unifiedStatus?: string,
): Record<string, unknown>[] {
if (!unifiedStatus) return docs;
return docs.filter((doc) => {
const publicId = String((doc as { publicId?: string }).publicId ?? "");
const claim = claimByPublicId.get(publicId);
return (
resolveUnifiedFileStatus({
blameStatus: String((doc as { status?: string }).status ?? ""),
claimStatus: claim?.status,
}) === unifiedStatus
);
});
}
async getUnifiedFileStatusReportV2(
actor: any,
query: UnifiedFileStatusReportQueryDto = {},
): Promise<UnifiedFileStatusReportDto> {
let rows = await this.collectBlamePortfolioDocs(actor);
const { fromDate, toDate } = parseListDateRange(query.from, query.to);
rows = rows.filter((doc) =>
isInListDateRange(
(doc as { createdAt?: Date }).createdAt,
fromDate,
toDate,
),
);
if (query.fileType) {
rows = rows.filter(
(doc) =>
String((doc as { type?: string }).type ?? "") === query.fileType,
);
}
const claimByPublicId = await this.loadClaimsByPublicIds(
rows.map((d) => String((d as { publicId?: string }).publicId ?? "")),
);
return {
statuses: getUnifiedFileStatusCatalog(),
counts: countUnifiedFileStatuses(
rows.map((doc) => {
const publicId = String((doc as { publicId?: string }).publicId ?? "");
const claim = claimByPublicId.get(publicId);
return {
blameStatus: String((doc as { status?: string }).status ?? ""),
claimStatus: claim?.status,
};
}),
),
};
}
private async collectBlamePortfolioDocs(
actor: any,
): Promise<Record<string, unknown>[]> {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return (await this.blameRequestDbService.find(
{
expertInitiated: true,
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
},
{ lean: true },
)) as Record<string, unknown>[];
}
requireActorClientKey(actor);
const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId);
const activityEvents = await this.expertFileActivityDbService.findByExpert(
expertId,
ExpertFileKind.BLAME,
);
const activityFileIds =
expertPortfolioFileIdsFromActivityEvents(activityEvents);
const orClauses: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
{ "expert.decision.decidedByExpertId": expertOid },
{ "expert.resend.requestedByExpertId": expertOid },
{ "workflow.lockedBy.actorId": expertOid },
];
const activityOids = objectIdsFromStringSet(activityFileIds);
if (activityOids.length > 0) {
orClauses.push({ _id: { $in: activityOids } });
}
const rows = (await this.blameRequestDbService.find(
{ blameStatus: BlameStatus.DISAGREEMENT, $or: orClauses },
{ lean: true },
)) as Record<string, unknown>[];
const seen = new Set<string>();
const out: Record<string, unknown>[] = [];
for (const doc of rows) {
const id = String(doc._id ?? "");
if (seen.has(id)) continue;
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
seen.add(id);
out.push(doc);
}
return out;
}
/** Load immutable profile fields from `expert` for the acting field expert. */ /** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) { private async snapshotFieldExpert(actorId: string) {
const expertDoc = await this.expertDbService.findOne({ const expertDoc = await this.expertDbService.findOne({
@@ -243,7 +377,9 @@ export class ExpertBlameService {
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file. * Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
*/ */
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> { async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
requireActorClientKey(actor); if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const expertId = String(actor.sub); const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId); const expertOid = new Types.ObjectId(expertId);
@@ -300,6 +436,12 @@ export class ExpertBlameService {
query: ListQueryV2Dto = {}, query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> { ): Promise<AllRequestDtoRsV2> {
try { try {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertBlameListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerBlameListV2(actor, query);
}
requireActorClientKey(actor); requireActorClientKey(actor);
const expertId = actor.sub; const expertId = actor.sub;
@@ -379,44 +521,8 @@ export class ExpertBlameService {
} }
} }
const paged = applyListQueryV2( const pagedResult = await this.paginateBlameListV2(visibleCases, query);
visibleCases, return pagedResult;
{
publicId: (doc) =>
String((doc as { publicId?: string }).publicId ?? ""),
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
requestNo: (doc) =>
String(
(doc as { requestNo?: string }).requestNo ??
(doc as { publicId?: string }).publicId ??
"",
),
status: (doc) => String((doc as { status?: string }).status ?? ""),
searchExtras: (doc) => {
const d = doc as {
_id?: unknown;
blameStatus?: string;
type?: string;
};
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
Boolean,
) as string[];
},
},
query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc),
);
return new AllRequestDtoRsV2({
data: items,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
});
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
@@ -429,8 +535,181 @@ export class ExpertBlameService {
} }
} }
/**
* Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created
* (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually
* via expert-claim after blame completion.
*/
private async getFieldExpertBlameListV2(
actor: { sub: string },
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
const expertId = actor.sub;
const expertOid = new Types.ObjectId(expertId);
const visibleCases = (await this.blameRequestDbService.find(
{
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
},
{ lean: true },
)) as Record<string, unknown>[];
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
staleIds.add(String(doc._id));
}
}
await Promise.all(
[...staleIds].map((id) =>
this.expireBlameCaseWorkflowLockV2IfStale(id),
),
);
for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue;
const w = doc.workflow as Record<string, unknown>;
if (w) {
w.locked = false;
delete w.lockedAt;
delete w.expiredAt;
delete w.lockedBy;
}
}
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult;
}
/**
* Blame inbox for FILE_REVIEWER — all expert-initiated IN_PERSON files that
* have been sealed by a FileMaker (WAITING_FOR_FILE_REVIEWER) and belong to
* this reviewer's insurance company (clientKey).
*
* Once the FileReviewer completes their steps the file moves to
* WAITING_FOR_EXPERT (via upload-video), after which it is visible in the
* standard expert-blame panel as usual.
*/
private async getFileReviewerBlameListV2(
actor: { sub: string; clientKey?: string },
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
const clientKey = requireActorClientKey(actor);
const allSealed = (await this.blameRequestDbService.find(
{
expertInitiated: true,
status: {
$in: [
CaseStatus.WAITING_FOR_FILE_REVIEWER,
CaseStatus.WAITING_FOR_EXPERT,
CaseStatus.COMPLETED,
],
},
},
{ lean: true },
)) as Record<string, unknown>[];
// Scope to this reviewer's insurance company via blame party clientId
const visibleCases = allSealed.filter((doc) =>
blameCaseTouchesClient(doc, clientKey),
);
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult;
}
private async paginateBlameListV2(
visibleCases: Record<string, unknown>[],
query: ListQueryV2Dto,
): Promise<AllRequestDtoRsV2> {
const claimByPublicId = await this.loadClaimsByPublicIds(
visibleCases.map((d) =>
String((d as { publicId?: string }).publicId ?? ""),
),
);
let filtered = this.filterBlameDocsByUnifiedStatus(
visibleCases,
claimByPublicId,
query.unifiedStatus,
);
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
filtered = filtered.filter((doc) =>
isInListDateRange(
(doc as { createdAt?: Date }).createdAt,
fromDate,
toDate,
),
);
}
const paged = applyListQueryV2(
filtered,
{
publicId: (doc) =>
String((doc as { publicId?: string }).publicId ?? ""),
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
requestNo: (doc) =>
String(
(doc as { requestNo?: string }).requestNo ??
(doc as { publicId?: string }).publicId ??
"",
),
status: (doc) => {
const publicId = String((doc as { publicId?: string }).publicId ?? "");
const claim = claimByPublicId.get(publicId);
return resolveUnifiedFileStatus({
blameStatus: String((doc as { status?: string }).status ?? ""),
claimStatus: claim?.status,
});
},
fileType: (doc) =>
String((doc as { type?: string }).type ?? "") || undefined,
searchExtras: (doc) => {
const d = doc as {
_id?: unknown;
blameStatus?: string;
type?: string;
publicId?: string;
status?: string;
};
const publicId = String(d.publicId ?? "");
const claim = claimByPublicId.get(publicId);
const unified = resolveUnifiedFileStatus({
blameStatus: String(d.status ?? ""),
claimStatus: claim?.status,
});
return [
String(d._id ?? ""),
d.blameStatus,
d.type,
unified,
claim?.status,
].filter(Boolean) as string[];
},
},
query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc, claimByPublicId),
);
return new AllRequestDtoRsV2({
data: items,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
});
}
private mapBlameRequestToListItemV2( private mapBlameRequestToListItemV2(
doc: Record<string, unknown>, doc: Record<string, unknown>,
claimByPublicId?: Map<string, { status?: string }>,
): AllRequestDtoV2 { ): AllRequestDtoV2 {
const createdAt = doc.createdAt const createdAt = doc.createdAt
? new Date(doc.createdAt as string) ? new Date(doc.createdAt as string)
@@ -461,10 +740,17 @@ export class ExpertBlameService {
const firstParty = parties.find((p) => p.role === "FIRST"); const firstParty = parties.find((p) => p.role === "FIRST");
const secondParty = parties.find((p) => p.role === "SECOND"); const secondParty = parties.find((p) => p.role === "SECOND");
const publicId = String(doc.publicId ?? "");
const linkedClaim = claimByPublicId?.get(publicId);
const unifiedFileStatus = resolveUnifiedFileStatus({
blameStatus: String(doc.status ?? ""),
claimStatus: linkedClaim?.status,
});
return { return {
requestId: String(doc._id), requestId: String(doc._id),
status: String(doc.status ?? ""), status: String(doc.status ?? ""),
unifiedFileStatus,
userComment: null, userComment: null,
requestCode: String(doc.publicId ?? ""), requestCode: String(doc.publicId ?? ""),
date, date,
@@ -493,6 +779,10 @@ export class ExpertBlameService {
secondParty?.vehicle?.inquiry?.mapped?.CarName || secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
}, },
needsFileReviewerCompletion:
String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER ||
(!!(doc as any).isMadeByFileMaker &&
String(doc.status ?? "") === CaseStatus.WAITING_FOR_EXPERT),
}; };
} }
@@ -512,11 +802,11 @@ export class ExpertBlameService {
} }
const la = doc.workflow.lockedAt; const la = doc.workflow.lockedAt;
if (!la) return true; if (!la) return true;
return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs; return Date.now() < new Date(la as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
} }
/** /**
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}. * Persist unlock when blame V2 workflow lock is older than {@link EXPERT_WORKFLOW_LOCK_TTL_MS}.
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now. * V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
*/ */
private async expireBlameCaseWorkflowLockV2IfStale( private async expireBlameCaseWorkflowLockV2IfStale(
@@ -534,7 +824,7 @@ export class ExpertBlameService {
} }
if ( if (
lockedAt && lockedAt &&
Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs Date.now() < new Date(lockedAt as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS
) { ) {
return; return;
} }
@@ -859,7 +1149,12 @@ export class ExpertBlameService {
// Tenant check // Tenant check
assertBlameCaseForExpertTenant(doc, actor); assertBlameCaseForExpertTenant(doc, actor);
if (doc.type === BlameRequestType.CAR_BODY) { const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
if (
doc.type === BlameRequestType.CAR_BODY &&
!isFieldExpertOwner
) {
throw new ForbiddenException( throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.", "CAR_BODY type requests are automatically handled and do not require expert review.",
); );
@@ -888,6 +1183,7 @@ export class ExpertBlameService {
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
); );
if (!isFieldExpertOwner) {
// Access gates — must satisfy at least one bucket // Access gates — must satisfy at least one bucket
const isAvailable = const isAvailable =
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId; doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
@@ -912,6 +1208,7 @@ export class ExpertBlameService {
"You do not have permission to view this request.", "You do not have permission to view this request.",
); );
} }
}
// Build evidence URLs // Build evidence URLs
const parties = Array.isArray(doc.parties) ? doc.parties : []; const parties = Array.isArray(doc.parties) ? doc.parties : [];
@@ -1006,7 +1303,7 @@ export class ExpertBlameService {
throw new BadRequestException("Request is locked by another expert"); throw new BadRequestException("Request is locked by another expert");
} }
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const lockExpiresAt = expertWorkflowLockExpiredAt();
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updateResult = await this.requestManagementDbService.findOneAndUpdate( const updateResult = await this.requestManagementDbService.findOneAndUpdate(
@@ -1018,7 +1315,7 @@ export class ExpertBlameService {
$set: { $set: {
lockFile: true, lockFile: true,
blameStatus: ReqBlameStatus.ReviewRequest, blameStatus: ReqBlameStatus.ReviewRequest,
unlockTime: fifteenMinutes, unlockTime: lockExpiresAt,
lockTime: new Date(), lockTime: new Date(),
actorLocked: { actorLocked: {
fullName: actorDetail.fullName, fullName: actorDetail.fullName,
@@ -1068,7 +1365,12 @@ export class ExpertBlameService {
*/ */
async assignBlameCaseForReviewV2( async assignBlameCaseForReviewV2(
requestId: string, requestId: string,
actor: { sub: string; fullName?: string; clientKey?: string }, actor: {
sub: string;
fullName?: string;
clientKey?: string;
role?: string;
},
): Promise<ExpertFileAssignResultDto> { ): Promise<ExpertFileAssignResultDto> {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
@@ -1079,6 +1381,8 @@ export class ExpertBlameService {
assertBlameCaseForExpertTenant(request, actor); assertBlameCaseForExpertTenant(request, actor);
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor);
if (request.type === BlameRequestType.CAR_BODY) { if (request.type === BlameRequestType.CAR_BODY) {
throw new BadRequestException({ throw new BadRequestException({
success: false, success: false,
@@ -1087,9 +1391,17 @@ export class ExpertBlameService {
}); });
} }
if (request.blameStatus !== BlameStatus.DISAGREEMENT) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
}
if ( if (
request.status !== CaseStatus.WAITING_FOR_EXPERT || !isFieldExpertOwner &&
request.blameStatus !== BlameStatus.DISAGREEMENT request.status !== CaseStatus.WAITING_FOR_EXPERT
) { ) {
throw new BadRequestException({ throw new BadRequestException({
success: false, success: false,
@@ -1098,6 +1410,26 @@ export class ExpertBlameService {
}); });
} }
if (isFieldExpertOwner && request.expert?.decision) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This request already has an expert decision",
});
}
if (
isFieldExpertOwner &&
request.status !== CaseStatus.WAITING_FOR_EXPERT
) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message:
"Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).",
});
}
if ( if (
request.expertInitiated && request.expertInitiated &&
request.initiatedByFieldExpertId && request.initiatedByFieldExpertId &&
@@ -1160,7 +1492,7 @@ export class ExpertBlameService {
$set: { $set: {
"workflow.locked": true, "workflow.locked": true,
"workflow.lockedAt": now, "workflow.lockedAt": now,
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs), "workflow.expiredAt": new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS),
"workflow.lockedBy": lockedByPayload, "workflow.lockedBy": lockedByPayload,
}, },
$push: { $push: {
@@ -1182,7 +1514,6 @@ export class ExpertBlameService {
const assignFilter: Record<string, unknown> = { const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId), _id: new Types.ObjectId(requestId),
status: CaseStatus.WAITING_FOR_EXPERT,
blameStatus: BlameStatus.DISAGREEMENT, blameStatus: BlameStatus.DISAGREEMENT,
$and: [ $and: [
{ {
@@ -1203,8 +1534,11 @@ export class ExpertBlameService {
], ],
}; };
if (request.expertInitiated) { if (isFieldExpertOwner) {
assignFilter.initiatedByFieldExpertId = expertOid; assignFilter.initiatedByFieldExpertId = expertOid;
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
} else {
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
} }
const updated = await this.blameRequestDbService.findOneAndUpdate( const updated = await this.blameRequestDbService.findOneAndUpdate(
@@ -1289,7 +1623,7 @@ export class ExpertBlameService {
} }
/** /**
* V2: Lock blame case for 15 minutes (blameCases collection). * V2: Lock blame case for 30 minutes (blameCases collection).
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients. * Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
*/ */
async lockRequestV2( async lockRequestV2(
@@ -1344,7 +1678,9 @@ export class ExpertBlameService {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor); if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const actorId = actor.sub; const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
@@ -1372,7 +1708,7 @@ export class ExpertBlameService {
const lockedAt = request.workflow?.lockedAt; const lockedAt = request.workflow?.lockedAt;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs; new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
if (Date.now() > lockExpiryTime) { if (Date.now() > lockExpiryTime) {
throw new ForbiddenException( throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.", "Your lock time has expired. Please lock the request again.",
@@ -1535,6 +1871,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
role, role,
"v1",
), ),
}); });
} }
@@ -1584,7 +1921,9 @@ export class ExpertBlameService {
): Promise<{ requestId: string; status: string }> { ): Promise<{ requestId: string; status: string }> {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor); if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const actorId = actor.sub; const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
@@ -1612,11 +1951,11 @@ export class ExpertBlameService {
const lockedAt = request.workflow?.lockedAt; const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId; const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes) // Check if lock has expired (30 minutes)
let isLockExpired = false; let isLockExpired = false;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs; new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
isLockExpired = Date.now() > lockExpiryTime; isLockExpired = Date.now() > lockExpiryTime;
} }
@@ -1727,6 +2066,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
linkRole, linkRole,
"v1",
), ),
}); });
} }
@@ -1778,11 +2118,11 @@ export class ExpertBlameService {
const lockedAt = request.workflow?.lockedAt; const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId; const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes) // Check if lock has expired (30 minutes)
let isLockExpired = false; let isLockExpired = false;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs; new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
isLockExpired = Date.now() > lockExpiryTime; isLockExpired = Date.now() > lockExpiryTime;
} }
@@ -2145,21 +2485,7 @@ export class ExpertBlameService {
} }
async getAccidentField() { async getAccidentField() {
try { return await this.lookupsService.getAccidentFields();
const ac_reason = await readFile(
"src/static/ACCIDENT_REASON.json",
"utf-8",
);
const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8");
const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8");
return {
accidentReason: JSON.parse(ac_reason),
accidentType: JSON.parse(ac_type),
accidentWay: JSON.parse(ac_way),
};
} catch (err) {
this.logger.error(err);
}
} }
async inPersonVisit(requestId: string, actorDetail: any) { async inPersonVisit(requestId: string, actorDetail: any) {

View File

@@ -26,6 +26,10 @@ import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { AllRequestDtoRsV2 } from "./dto/all-request.dto"; import { AllRequestDtoRsV2 } from "./dto/all-request.dto";
import { SubmitReplyDto } from "./dto/reply.dto"; import { SubmitReplyDto } from "./dto/reply.dto";
import { ResendRequestDto } from "./dto/resend.dto"; import { ResendRequestDto } from "./dto/resend.dto";
@@ -40,10 +44,13 @@ export class ExpertBlameV2Controller {
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: "List blame cases for field expert (V2)", summary: "List blame cases for expert review (V2)",
description: description:
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
"Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " +
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
}) })
@ApiResponse({ status: 200, type: AllRequestDtoRsV2 })
async findAll( async findAll(
@CurrentUser() actor: any, @CurrentUser() actor: any,
@Query() query: ListQueryV2Dto, @Query() query: ListQueryV2Dto,
@@ -58,11 +65,38 @@ export class ExpertBlameV2Controller {
} }
} }
@Get("report/unified-file-statuses")
@ApiOperation({
summary: "Unified file status catalog + counts (blame + linked claim)",
description:
"Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` and `fileType` query params. Optional `from` / `to` ISO dates narrow the counted portfolio.",
})
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
async getUnifiedFileStatusReportV2(
@CurrentUser() actor: any,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
try {
return await this.expertBlameService.getUnifiedFileStatusReportV2(
actor,
query,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to load unified file status report",
);
}
}
@Get("report/status-counts") @Get("report/status-counts")
@ApiOperation({ @ApiOperation({
summary: "Count blame cases by grouped status bucket (this field expert)", summary: "Count blame cases by grouped status bucket (this field expert)",
deprecated: true,
description: description:
"Counts only files in the acting experts portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.", "Legacy blame-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.",
}) })
async getStatusReportBucketsV2(@CurrentUser() actor: any) { async getStatusReportBucketsV2(@CurrentUser() actor: any) {
try { try {

View File

@@ -1,5 +1,6 @@
// claim-damaged-part.enricher.ts // claim-damaged-part.enricher.ts
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
import { import {
DamagedPartCaptureStatus, DamagedPartCaptureStatus,
EnrichedDamagedPart, EnrichedDamagedPart,
@@ -14,6 +15,9 @@ export function buildEnrichedDamagedParts(params: {
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean; hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
catalogLikeKeyFromPart: (part: any) => string; catalogLikeKeyFromPart: (part: any) => string;
buildFileLink: (path: string) => string; buildFileLink: (path: string) => string;
resolveStoredFileUrl?: (
stored?: { url?: string; path?: string } | null,
) => string | undefined;
}): EnrichedDamagedPart[] { }): EnrichedDamagedPart[] {
const { const {
selectedParts, selectedParts,
@@ -24,6 +28,7 @@ export function buildEnrichedDamagedParts(params: {
hasDamagedPartCapture, hasDamagedPartCapture,
catalogLikeKeyFromPart, catalogLikeKeyFromPart,
buildFileLink, buildFileLink,
resolveStoredFileUrl: resolveUrl = resolveStoredFileUrl,
} = params; } = params;
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? []; const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
@@ -109,17 +114,13 @@ export function buildEnrichedDamagedParts(params: {
: false); : false);
const capUrl = const capUrl =
cap?.url || resolveUrl(cap) ||
(cap?.path ? buildFileLink(cap.path) : undefined) ||
(sp.id == null && Array.isArray(damagedPartsData) (sp.id == null && Array.isArray(damagedPartsData)
? (() => { ? (() => {
const match = (damagedPartsData as any[]).find( const match = (damagedPartsData as any[]).find(
(d) => d.name === sp.name && d.side === sp.side && d.path, (d) => d.name === sp.name && d.side === sp.side && d.path,
); );
return ( return resolveUrl(match);
match?.url ||
(match?.path ? buildFileLink(match.path) : undefined)
);
})() })()
: undefined); : undefined);

View File

@@ -131,16 +131,26 @@ export class ClaimDetailV2ResponseDto {
}) })
awaitingFactorValidation?: boolean; awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
example: true,
})
requiresFileMakerApproval?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"Slice of `claim.evaluation` exposed to the damage expert. " + "Slice of `claim.evaluation` exposed to the damage expert. " +
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " + "`damageExpertReply` / `damageExpertReplyFinal` are returned whenever " +
"awaiting factor validation. `ownerInsurerApproval` and " + "present, regardless of the current claim status — historical assessment " +
"`ownerPricedPartsApproval` are returned whenever they exist on the " + "data remains visible once the expert has submitted it. " +
"claim — each carries `signLink` (resolved from `signDetailId`) so the " + "`ownerInsurerApproval` and `ownerPricedPartsApproval` are returned " +
"front-end can render the user signature directly. Likewise, " + "whenever they exist on the claim — each carries `signLink` (resolved " +
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " + "from `signDetailId`) so the front-end can render the user signature " +
"expose `signLink` when a user comment signature is present.", "directly. Likewise, `damageExpertReply.userComment` / " +
"`damageExpertReplyFinal.userComment` expose `signLink` when a user " +
"comment signature is present. `priceDrop` is included whenever it " +
"has been saved, not only during the expert review phase.",
}) })
evaluation?: { evaluation?: {
damageExpertReply?: unknown; damageExpertReply?: unknown;

View File

@@ -15,6 +15,12 @@ export class ClaimListItemV2Dto {
}) })
status: string; status: string;
@ApiPropertyOptional({
description: "Calculated unified blame+claim lifecycle status",
example: "WAITING_FOR_DAMAGE_EXPERT",
})
unifiedFileStatus?: string;
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' }) @ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
currentStep: string; currentStep: string;
@@ -50,6 +56,12 @@ export class ClaimListItemV2Dto {
}) })
carBodyFirstForm?: { car?: boolean; object?: boolean }; carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({
description: 'Linked blame file ID (present when the claim originates from a blame case)',
example: '6a3a6f171aadfa0cc313c582',
})
blameRequestId?: string;
@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;
@@ -58,6 +70,13 @@ export class ClaimListItemV2Dto {
"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`.", "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; awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
example: true,
})
requiresFileMakerApproval?: boolean;
} }
export class GetClaimListV2ResponseDto { export class GetClaimListV2ResponseDto {

View File

@@ -0,0 +1,25 @@
import { HttpException, HttpStatus } from "@nestjs/common";
/**
* Thrown when a well-formed request violates a business rule that cannot be
* expressed as a generic validation error. Returns HTTP 422 with a structured
* body so the frontend can branch on `errorCode` without string-matching the
* human-readable `message`.
*
* Response body shape:
* ```json
* { "errorCode": "SOME_CODE", "message": "Human-readable explanation." }
* ```
*/
export class BusinessRuleException extends HttpException {
constructor(
public readonly errorCode: string,
message: string,
) {
super({ errorCode, message }, HttpStatus.UNPROCESSABLE_ENTITY);
}
}
export const BusinessErrorCode = {
DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
} as const;

View File

@@ -18,6 +18,7 @@ import {
ApiQuery, ApiQuery,
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiExcludeController,
} 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";
@@ -33,7 +34,8 @@ import { ParseJsonPipe } from "src/utils/pipes/parse-json.pipe";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { FactorValidationDto } from "./dto/factor-validation.dto"; import { FactorValidationDto } from "./dto/factor-validation.dto";
@ApiTags("expert-claim-panel") @ApiExcludeController()
// @ApiTags("expert-claim-panel")
@Controller("expert-claim") @Controller("expert-claim")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@@ -41,20 +43,20 @@ 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 }) // @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 }) // @ApiOperation({ deprecated: true })
@Get("/:id") @Get("/:id")
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
@ApiQuery({ // @ApiQuery({
name: "updateDropPrice", // name: "updateDropPrice",
required: false, // required: false,
description: "A stringified JSON object with price drop override values.", // description: "A stringified JSON object with price drop override values.",
}) // })
async getClaimRequestPerId( async getClaimRequestPerId(
@Param("id") id: string, @Param("id") id: string,
@CurrentUser() currentUser, @CurrentUser() currentUser,
@@ -67,16 +69,16 @@ export class ExpertClaimController {
); );
} }
@ApiOperation({ deprecated: true }) // @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 }) // @ApiOperation({ deprecated: true })
@Get("required-documents/:id") @Get("required-documents/:id")
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
async getRequiredDocuments( async getRequiredDocuments(
@Param("id") id: string, @Param("id") id: string,
@CurrentUser() currentUser, @CurrentUser() currentUser,
@@ -84,7 +86,7 @@ export class ExpertClaimController {
return await this.expertClaimService.getRequiredDocuments(id, currentUser); return await this.expertClaimService.getRequiredDocuments(id, currentUser);
} }
@ApiOperation({ deprecated: true }) // @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);
@@ -92,7 +94,7 @@ export class ExpertClaimController {
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Put("reply/submit/:id") @Put("reply/submit/:id")
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
async submitReplyRequest( async submitReplyRequest(
@Param("id") requestId, @Param("id") requestId,
@Body() body: ClaimSubmitReplyDto, @Body() body: ClaimSubmitReplyDto,
@@ -105,9 +107,9 @@ export class ExpertClaimController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Put("resend/submit/:id") @Put("resend/submit/:id")
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
async submitResend( async submitResend(
@Param("id") requestId, @Param("id") requestId,
@Body() body: ClaimSubmitResendDto, @Body() body: ClaimSubmitResendDto,
@@ -120,12 +122,12 @@ export class ExpertClaimController {
); );
} }
@ApiQuery({ // @ApiQuery({
name: "query", // name: "query",
enum: ["car-capture", "accident"], //enum: ["car-capture", "accident"],
required: true, //required: true,
}) //})
@ApiOperation({ deprecated: 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")
@@ -143,7 +145,7 @@ export class ExpertClaimController {
enum: ["car-capture", "accident"], enum: ["car-capture", "accident"],
required: true, required: true,
}) })
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Get("link/:id/video") @Get("link/:id/video")
@ApiParam({ name: "id" }) @ApiParam({ name: "id" })
async getClaimVideoLink( async getClaimVideoLink(
@@ -153,17 +155,17 @@ export class ExpertClaimController {
return this.expertClaimService.getVideoLink(id, query); return this.expertClaimService.getVideoLink(id, query);
} }
@ApiOperation({ deprecated: true }) // @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 }) // @ApiOperation({ deprecated: true })
@Patch("validate-factors/:claimId") @Patch("validate-factors/:claimId")
@ApiParam({ name: "claimId" }) // @ApiParam({ name: "claimId" })
@ApiBody({ type: FactorValidationDto }) // @ApiBody({ type: FactorValidationDto })
@Roles(RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.DAMAGE_EXPERT)
async validateFactors( async validateFactors(
@Param("claimId") claimId: string, @Param("claimId") claimId: string,
@@ -177,16 +179,16 @@ export class ExpertClaimController {
); );
} }
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
@ApiOperation({ deprecated: true }) // @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 }) // @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) {
return await this.expertClaimService.retrieveInsuranceBranches(insuranceId); return await this.expertClaimService.retrieveInsuranceBranches(insuranceId);
} }

View File

@@ -1,5 +1,7 @@
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { AiModule } from "src/ai/ai.module"; import { AiModule } from "src/ai/ai.module";
import { AuthModule } from "src/auth/auth.module"; import { AuthModule } from "src/auth/auth.module";
@@ -21,7 +23,11 @@ import { ExpertClaimService } from "./expert-claim.service";
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
SandHubModule, SandHubModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: ClaimFactorsImage.name, schema: ClaimFactorsImageSchema }, { name: ClaimFactorsImage.name, schema: ClaimFactorsImageSchema },

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { import {
Body, Body,
Controller, Controller,
@@ -31,6 +32,10 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
import { import {
ClaimSubmitResendV2Dto, ClaimSubmitResendV2Dto,
@@ -58,18 +63,36 @@ class InPersonVisitV2Dto {
@Controller("v2/expert-claim") @Controller("v2/expert-claim")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER)
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor( constructor(
private readonly expertClaimService: ExpertClaimService, private readonly expertClaimService: ExpertClaimService,
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
) {} ) {}
@Get("report/unified-file-statuses")
@ApiOperation({
summary: "Unified file status catalog + counts (claim + linked blame)",
description:
"Calculatable statuses for this expert's claim portfolio. Filter lists with `unifiedStatus` and `fileType` query params. Optional `from` / `to` ISO dates narrow the counted portfolio.",
})
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
async getUnifiedFileStatusReportV2(
@CurrentUser() actor: any,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return await this.expertClaimService.getUnifiedFileStatusReportV2(
actor,
query,
);
}
@Get("report/status-counts") @Get("report/status-counts")
@ApiOperation({ @ApiOperation({
summary: "Count claim cases by grouped status bucket (this damage expert)", summary: "Count claim cases by grouped status bucket (this damage expert)",
deprecated: true,
description: description:
"Counts only files in the acting experts portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.", "Legacy claim-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.",
}) })
async getStatusReportBucketsV2(@CurrentUser() actor: any) { async getStatusReportBucketsV2(@CurrentUser() actor: any) {
return await this.expertClaimService.getStatusReportBucketsV2(actor); return await this.expertClaimService.getStatusReportBucketsV2(actor);
@@ -95,6 +118,21 @@ export class ExpertClaimV2Controller {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
} }
@Get("inner-parts-catalog")
@ApiOperation({
summary: "Get inner parts catalog",
description: "Returns the full list of inner car parts from the static catalog.",
})
@ApiResponse({ status: 200, description: "Inner parts catalog (object keyed by part name)" })
async getInnerPartsCatalog() {
const raw = await readFile(`${process.cwd()}/src/static/car-part.json`, "utf-8");
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Get("branches") @Get("branches")
@ApiOperation({ @ApiOperation({
summary: "List insurer branches for this damage expert (V2)", summary: "List insurer branches for this damage expert (V2)",
@@ -115,7 +153,9 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
description: description:
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", "Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " +
"Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). " +
"Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
}) })
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto }) @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
async getClaimListV2( async getClaimListV2(
@@ -129,7 +169,7 @@ export class ExpertClaimV2Controller {
@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, damage selections, `evaluation.priceDrop` during damage review, `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.", "Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. 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(
@@ -257,7 +297,20 @@ export class ExpertClaimV2Controller {
description: 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`. " + "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" + "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.", "**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **422** with `errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED`—the expert may only submit a priced reply or request in-person visit afterward.",
})
@ApiResponse({
status: 422,
description:
"Business rule violation: resend limit exceeded. " +
"`errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED` — the owner has already fulfilled a prior resend request for this claim.",
schema: {
example: {
errorCode: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
message:
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
},
},
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto }) @ApiBody({ type: ClaimSubmitResendV2Dto })

View File

@@ -81,3 +81,19 @@ export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
}) })
role?: RoleEnum.DAMAGE_EXPERT; role?: RoleEnum.DAMAGE_EXPERT;
} }
export class CreateFileMakerByInsurerDto extends CreateInsurerExpertDto {
@ApiPropertyOptional({
enum: [RoleEnum.FILE_MAKER],
default: RoleEnum.FILE_MAKER,
})
role?: RoleEnum.FILE_MAKER;
}
export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto {
@ApiPropertyOptional({
enum: [RoleEnum.FILE_REVIEWER],
default: RoleEnum.FILE_REVIEWER,
})
role?: RoleEnum.FILE_REVIEWER;
}

View File

@@ -16,6 +16,7 @@ import {
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Types } from "mongoose"; import { Types } from "mongoose";
@@ -26,10 +27,17 @@ import { CurrentUser } from "src/decorators/user.decorator";
import { FileRating } from "src/request-management/entities/schema/request-management.schema"; import { FileRating } from "src/request-management/entities/schema/request-management.schema";
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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { import {
CreateBlameExpertByInsurerDto, CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto, CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "./dto/create-insurer-expert.dto"; } from "./dto/create-insurer-expert.dto";
@Controller("expert-insurer") @Controller("expert-insurer")
@@ -91,20 +99,32 @@ export class ExpertInsurerController {
@Put("branches/:branchId/status") @Put("branches/:branchId/status")
@ApiParam({ name: "branchId" }) @ApiParam({ name: "branchId" })
@ApiQuery({ name: "isActive", type: Boolean }) @ApiBody({
schema: {
type: "object",
properties: { isActive: { type: "boolean" } },
required: ["isActive"],
},
})
async setBranchStatus( async setBranchStatus(
@CurrentUser() insurer, @CurrentUser() insurer,
@Param("branchId") branchId: string, @Param("branchId") branchId: string,
@Query("isActive") isActive: string, @Body("isActive") isActive: unknown,
) { ) {
if (!insurer) { if (!insurer) {
throw new UnauthorizedException("Could not identify the current user."); throw new UnauthorizedException("Could not identify the current user.");
} }
const normalized = String(isActive).trim().toLowerCase(); // Accept native boolean (JSON body) or string coercion (legacy query/form usage)
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { let active: boolean;
throw new BadRequestException("isActive must be true/false"); if (typeof isActive === "boolean") {
active = isActive;
} else {
const normalized = String(isActive ?? "").trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be a boolean");
}
active = ["true", "1", "yes"].includes(normalized);
} }
const active = ["true", "1", "yes"].includes(normalized);
return this.expertInsurerService.setBranchActive( return this.expertInsurerService.setBranchActive(
insurer.clientKey, insurer.clientKey,
branchId, branchId,
@@ -136,6 +156,32 @@ export class ExpertInsurerController {
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body); return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
} }
@Post("experts/file-maker")
@ApiBody({ type: CreateFileMakerByInsurerDto })
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
async addFileMaker(
@CurrentUser() insurer,
@Body() body: CreateFileMakerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
}
@Post("experts/file-reviewer")
@ApiBody({ type: CreateFileReviewerByInsurerDto })
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
async addFileReviewer(
@CurrentUser() insurer,
@Body() body: CreateFileReviewerByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addFileReviewer(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("experts/list") @Get("experts/list")
@@ -162,9 +208,52 @@ export class ExpertInsurerController {
} }
@Get("files") @Get("files")
async getAllFiles(@CurrentUser() insurer) { @ApiOperation({
summary: "List insurer files (blame + claim merged by publicId)",
description:
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
})
async getAllFiles(
@CurrentUser() insurer,
@Query() query: ListQueryV2Dto,
) {
return await this.expertInsurerService.retrieveAllFilesOfClient( return await this.expertInsurerService.retrieveAllFilesOfClient(
insurer.clientKey, insurer.clientKey,
query,
);
}
@Get("report/unified-file-statuses")
@ApiOperation({
summary: "Unified file status catalog + counts (blame + claim)",
description:
"Calculatable unified statuses and per-status counts for this insurer's files. Filter lists with `unifiedStatus` and `fileType`. Optional `from` / `to` ISO dates narrow the counted portfolio.",
})
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
async getUnifiedFileStatusReport(
@CurrentUser() actor,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(
actor,
query,
);
}
@Get("files/:publicId/timeline")
@ApiParam({ name: "publicId" })
@ApiOperation({
summary: "Activity timeline for a case",
description:
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
})
async getFileTimeline(
@CurrentUser() insurer,
@Param("publicId") publicId: string,
) {
return await this.expertInsurerService.getFileTimeline(
insurer.clientKey,
publicId,
); );
} }
@@ -265,6 +354,11 @@ export class ExpertInsurerController {
} }
@Get("report/status-counts") @Get("report/status-counts")
@ApiOperation({
summary: "Legacy status counts (mapped from unified statuses)",
deprecated: true,
description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.",
})
@ApiQuery({ @ApiQuery({
name: "from", name: "from",
required: false, required: false,

View File

@@ -46,5 +46,6 @@ import { HashModule } from "src/utils/hash/hash.module";
], ],
providers: [ExpertInsurerService], providers: [ExpertInsurerService],
controllers: [ExpertInsurerController], controllers: [ExpertInsurerController],
exports: [ExpertInsurerService],
}) })
export class ExpertInsurerModule {} export class ExpertInsurerModule {}

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