1
0
forked from Yara724/api

Compare commits

...

193 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
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
129 changed files with 15234 additions and 1085 deletions

View File

@@ -5,6 +5,10 @@ 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
# --------------------------------------------- # ---------------------------------------------
@@ -36,6 +40,7 @@ MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/$
# --------------------------------------------- # ---------------------------------------------
JWT_SECRET = JWT_SECRET =
JWT_EXPIRY = JWT_EXPIRY =
CAPTCHA_ENABLED = true
# --------------------------------------------- # ---------------------------------------------
# 🧩 SanHub Microservice # 🧩 SanHub Microservice
@@ -62,6 +67,23 @@ AUTH_SMS_TEMPLATE =
EXP_OTP_TIME = EXP_OTP_TIME =
FAKE_OTP = 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

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
}
]
} }
} }

188
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"
@@ -2107,9 +2109,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2127,9 +2126,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2147,9 +2143,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2167,9 +2160,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2187,9 +2177,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2957,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"
@@ -3253,9 +3251,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3273,9 +3268,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3293,9 +3285,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3313,9 +3302,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3417,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",
@@ -5144,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",
@@ -5400,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",
@@ -6238,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",
@@ -6842,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": {
@@ -7102,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",
@@ -8734,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",
@@ -8939,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",
@@ -9821,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",
@@ -9959,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",
@@ -10085,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",
@@ -10438,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",
@@ -11859,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

@@ -39,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": {

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

@@ -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";
@@ -25,9 +28,16 @@ import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { DatabaseModule } from "./core/database/database.module"; 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,
@@ -49,12 +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,
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) {

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
@@ -94,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;
} }

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

@@ -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

@@ -12,6 +12,8 @@ const GLOBAL_GUARD_ROLES = new Set<string>([
RoleEnum.USER, RoleEnum.USER,
RoleEnum.FIELD_EXPERT, RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR, RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER
]); ]);
@Injectable() @Injectable()

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

@@ -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";
@@ -52,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,

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, RoleEnum.REGISTRAR) @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,
@@ -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,48 +1112,10 @@ 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",
); );
} }
} }
@Get("fanavaran-submit/parsian/:claimCaseId")
@ApiOperation({
summary: "Preview Fanavaran submit body (Parsian / V2)",
description:
"Builds the third-party-car-financial-claims payload from claimCases + blameCases without calling Fanavaran.",
})
@ApiParam({
name: "claimCaseId",
description: "The claim case ID (MongoDB ObjectId)",
})
async fanavaranSubmitParsianV2(
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
) {
return await this.claimRequestManagementService.fanavaranSubmitParsianV2(
claimCaseId,
{
debug: debug === "1" || debug === "true",
},
);
}
@Post("fanavaran-submit/parsian/:claimCaseId")
@ApiOperation({
summary: "Submit claim to Fanavaran (Parsian / V2)",
description:
"Authenticates with Parsian Fanavaran credentials and submits the mapped claimCases + blameCases payload.",
})
@ApiParam({
name: "claimCaseId",
description: "The claim case ID (MongoDB ObjectId)",
})
async submitToFanavaranParsianV2(
@Param("claimCaseId") claimCaseId: string,
) {
return await this.claimRequestManagementService.submitToFanavaranParsianV2(
claimCaseId,
);
}
} }

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

@@ -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,
@@ -142,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;
@@ -184,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

@@ -4,9 +4,12 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
InternalServerErrorException,
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
@@ -516,6 +519,73 @@ Returns status of each item (uploaded/captured or not).
); );
} }
// ─── 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") @Patch("car-capture/:claimRequestId")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(

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

@@ -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

@@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
}) })
@IsBoolean() @IsBoolean()
carOwnership: boolean; 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( export class UpdateClientExternalInquiriesDto extends PartialType(

View File

@@ -20,7 +20,9 @@ export class AuthGuard implements CanActivate {
} }
try { try {
const payload: JwtPayload = const payload: JwtPayload =
await this.jwtService.verifyAsync<JwtPayload>(token); await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: `${process.env.JWT_SECRET}`,
});
request["user"] = payload; request["user"] = payload;
} catch { } catch {
throw new UnauthorizedException("Invalid or expired token"); throw new UnauthorizedException("Invalid or expired token");

View File

@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
import { import {
IsIn, IsIn,
IsInt, IsInt,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
Max, Max,
@@ -102,4 +103,20 @@ export class ListQueryV2Dto {
@IsOptional() @IsOptional()
@IsIn([...LIST_FILE_TYPE_V2]) @IsIn([...LIST_FILE_TYPE_V2])
fileType?: ListFileTypeV2; 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

@@ -6,6 +6,7 @@ export const EXTERNAL_INQUIRY_TYPES = [
"sheba", "sheba",
"drivingLicense", "drivingLicense",
"carOwnership", "carOwnership",
"vinChassis",
] as const; ] as const;
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number]; export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
@@ -21,6 +22,7 @@ export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
sheba: false, sheba: false,
drivingLicense: false, drivingLicense: false,
carOwnership: false, carOwnership: false,
vinChassis: false,
}; };
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>; export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;

View File

@@ -2,12 +2,10 @@ import {
IsBooleanString, IsBooleanString,
IsEnum, IsEnum,
IsNumber, IsNumber,
IsNumberString,
IsOptional, IsOptional,
IsPositive, IsPositive,
IsString, IsString,
IsUrl, IsUrl,
Length,
Matches, Matches,
Max, Max,
Min, Min,

View File

@@ -1,5 +1,27 @@
export type FanavaranClientKey = "parsian" | "tejaratno"; 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 { export interface FanavaranAuthConfig {
appName: string; appName: string;
secret: string; secret: string;
@@ -15,9 +37,17 @@ export interface FanavaranPayloadDefaults {
AccidentReportTypeId: number; AccidentReportTypeId: number;
AccidentVehicleUsedId: number; AccidentVehicleUsedId: number;
ClaimExpertId: number; ClaimExpertId: number;
ExpertiseClaimExpertId: number;
CompensationReferenceId: number; CompensationReferenceId: number;
CulpritLicenceTypeId: number; CulpritLicenceTypeId: number;
CulpritTypeId: number; CulpritTypeId: number;
DmgCaseTypeId: number;
DmgHistoryStatus: number;
PlaqueKindId: number;
PlaqueSampleId: number;
DriverIsOwner: number;
FaultPercent: number;
ClaimFileTypeId: number;
} }
export interface FanavaranClientProfile { export interface FanavaranClientProfile {
@@ -45,10 +75,18 @@ const FANAVARAN_CLIENT_PROFILES: Record<
AccidentCityId: 701, AccidentCityId: 701,
AccidentReportTypeId: 155, AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1, AccidentVehicleUsedId: 1,
ClaimExpertId: 1589, ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 4543092,
CompensationReferenceId: 167, CompensationReferenceId: 167,
CulpritLicenceTypeId: 2, CulpritLicenceTypeId: 2,
CulpritTypeId: 337, CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23,
}, },
}, },
parsian: { parsian: {
@@ -67,9 +105,17 @@ const FANAVARAN_CLIENT_PROFILES: Record<
AccidentReportTypeId: 155, AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1, AccidentVehicleUsedId: 1,
ClaimExpertId: 154, ClaimExpertId: 154,
ExpertiseClaimExpertId: 29,
CompensationReferenceId: 167, CompensationReferenceId: 167,
CulpritLicenceTypeId: 2, CulpritLicenceTypeId: 2,
CulpritTypeId: 337, CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 70,
}, },
}, },
}; };
@@ -93,6 +139,30 @@ export function resolveFanavaranClientProfile(): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()]; return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
} }
export function fanavaranManualSubmitPath(claimCaseId: string): string { export function getFanavaranClientProfile(
return `/v2/claim-request-management/fanavaran-submit/${claimCaseId}`; 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

@@ -121,6 +121,12 @@ export class AllRequestDtoV2 {
unifiedFileStatus?: string; 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

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

View File

@@ -12,6 +12,7 @@ import {
assertBlameCaseForExpertTenant, assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert, blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert, blameCaseInitiatedByFieldExpert,
blameCaseTouchesClient,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } from "src/helpers/tenant-scope";
import { import {
@@ -30,6 +31,7 @@ import {
} from "src/helpers/unified-file-status"; } from "src/helpers/unified-file-status";
import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2"; 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 { 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 { import {
UnifiedFileStatusReportDto, UnifiedFileStatusReportDto,
@@ -45,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 {
@@ -110,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,
@@ -126,6 +129,7 @@ export class ExpertBlameService {
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimCaseDbService: ClaimCaseDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly lookupsService: LookupsService,
) {} ) {}
private async loadClaimsByPublicIds( private async loadClaimsByPublicIds(
@@ -435,6 +439,9 @@ export class ExpertBlameService {
if (actor.role === RoleEnum.FIELD_EXPERT) { if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertBlameListV2(actor, query); 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;
@@ -576,6 +583,44 @@ export class ExpertBlameService {
return pagedResult; 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( private async paginateBlameListV2(
visibleCases: Record<string, unknown>[], visibleCases: Record<string, unknown>[],
query: ListQueryV2Dto, query: ListQueryV2Dto,
@@ -585,11 +630,21 @@ export class ExpertBlameService {
String((d as { publicId?: string }).publicId ?? ""), String((d as { publicId?: string }).publicId ?? ""),
), ),
); );
const filtered = this.filterBlameDocsByUnifiedStatus( let filtered = this.filterBlameDocsByUnifiedStatus(
visibleCases, visibleCases,
claimByPublicId, claimByPublicId,
query.unifiedStatus, 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( const paged = applyListQueryV2(
filtered, filtered,
@@ -724,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),
}; };
} }
@@ -743,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(
@@ -765,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;
} }
@@ -1244,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(
@@ -1256,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,
@@ -1433,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: {
@@ -1564,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(
@@ -1649,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.",
@@ -1812,6 +1871,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
role, role,
"v1",
), ),
}); });
} }
@@ -1891,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;
} }
@@ -2006,6 +2066,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink( link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken, requestIdToken,
linkRole, linkRole,
"v1",
), ),
}); });
} }
@@ -2057,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;
} }
@@ -2424,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

@@ -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

@@ -56,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;
@@ -64,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

@@ -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,
@@ -62,7 +63,7 @@ class InPersonVisitV2Dto {
@Controller("v2/expert-claim") @Controller("v2/expert-claim")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_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,
@@ -117,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)",
@@ -153,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(
@@ -281,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

@@ -36,6 +36,8 @@ 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")
@@ -97,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,
@@ -142,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")
@@ -200,6 +240,23 @@ export class ExpertInsurerController {
); );
} }
@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,
);
}
@Get("files/:publicId") @Get("files/:publicId")
@ApiParam({ name: "publicId" }) @ApiParam({ name: "publicId" })
async getFileDetailsByPublicId( async getFileDetailsByPublicId(

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 {}

View File

@@ -12,6 +12,8 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic
import { import {
CreateBlameExpertByInsurerDto, CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto, CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
CreateInsurerExpertDto, CreateInsurerExpertDto,
} from "./dto/create-insurer-expert.dto"; } from "./dto/create-insurer-expert.dto";
import { import {
@@ -25,6 +27,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema"; import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
@@ -71,6 +75,7 @@ import {
extractExpertNamesFromBlame, extractExpertNamesFromBlame,
extractExpertNamesFromClaim, extractExpertNamesFromClaim,
} from "./helper/insurer.helper"; } from "./helper/insurer.helper";
import { getEventFaLabel } from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
@Injectable() @Injectable()
@@ -89,6 +94,8 @@ export class ExpertInsurerService {
private readonly videoCaptureDbService: VideoCaptureDbService, private readonly videoCaptureDbService: VideoCaptureDbService,
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly claimSignDbService: ClaimSignDbService, private readonly claimSignDbService: ClaimSignDbService,
private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService,
) {} ) {}
/** /**
@@ -1328,6 +1335,12 @@ export class ExpertInsurerService {
(f) => f.unifiedFileStatus === query.unifiedStatus, (f) => f.unifiedFileStatus === query.unifiedStatus,
); );
} }
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
files = files.filter((f) =>
isInListDateRange(f.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
files, files,
@@ -1513,6 +1526,7 @@ export class ExpertInsurerService {
ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES, ClaimCaseStatus.CAPTURING_PART_DAMAGES,
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
ClaimCaseStatus.WAITING_FOR_USER_RESEND, ClaimCaseStatus.WAITING_FOR_USER_RESEND,
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
ClaimCaseStatus.EXPERT_REVIEWING, ClaimCaseStatus.EXPERT_REVIEWING,
@@ -1651,6 +1665,108 @@ export class ExpertInsurerService {
); );
} }
async addFileMaker(
insurerClientKey: string,
payload: CreateFileMakerByInsurerDto,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existing = await this.fileMakerDbService.findOne({ email });
if (existing) {
throw new ConflictException("A FileMaker with this email already exists.");
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.fileMakerDbService.findOne({
nationalCode,
});
if (existingByNational) {
throw new ConflictException(
"A FileMaker with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const created = await this.fileMakerDbService.create({
...payload,
email,
nationalCode,
password: hashedPassword,
role: RoleEnum.FILE_MAKER,
clientKey: clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
});
return {
fileMaker: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
async addFileReviewer(
insurerClientKey: string,
payload: CreateFileReviewerByInsurerDto,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existing = await this.fileReviewerDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A FileReviewer with this email already exists.",
);
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.fileReviewerDbService.findOne({
nationalCode,
});
if (existingByNational) {
throw new ConflictException(
"A FileReviewer with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const created = await this.fileReviewerDbService.create({
...payload,
email,
nationalCode,
password: hashedPassword,
role: RoleEnum.FILE_REVIEWER,
clientKey: clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
});
return {
fileReviewer: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
/** /**
* Get comprehensive statistics for all experts of a client * Get comprehensive statistics for all experts of a client
* Returns: * Returns:
@@ -1973,4 +2089,77 @@ export class ExpertInsurerService {
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0, waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
}; };
} }
async getFileTimeline(
insurerId: string,
publicId: string,
): Promise<{ publicId: string; events: object[] }> {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
const id = this.getClientId(insurerId);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find(
(b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
const events: object[] = [];
if (blame) {
const blameDoc = await this.blameRequestDbService.findById(
String((blame as any)._id),
);
const blameHistory: any[] = (blameDoc as any)?.history ?? [];
for (const ev of blameHistory) {
events.push({
source: "blame",
type: ev.type,
faLabel: getEventFaLabel(ev),
timestamp: ev.timestamp,
actor: ev.actor ?? null,
metadata: ev.metadata ?? null,
});
}
}
if (claim) {
const claimId = String((claim as any)._id);
const rows = (await this.claimCaseDbService.find(
{ _id: new Types.ObjectId(claimId) },
{ lean: true, select: "history createdAt" },
)) as Record<string, unknown>[];
const claimHistory: any[] = (rows[0] as any)?.history ?? [];
for (const ev of claimHistory) {
events.push({
source: "claim",
type: ev.type,
faLabel: getEventFaLabel(ev),
timestamp: ev.timestamp,
actor: ev.actor ?? null,
metadata: ev.metadata ?? null,
});
}
}
events.sort((a: any, b: any) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
});
// Resolve insurer's client name for context if needed — just return raw events
return { publicId, events };
}
} }

View File

@@ -0,0 +1,196 @@
/**
* Persian (Farsi) display labels for every timeline event `type` that can
* appear in a blame or claim history array.
*
* For generic `STEP_COMPLETED` / `V3_STEP_COMPLETED` events the label is
* derived from the `metadata.stepKey` value; those keys are listed at the
* bottom under STEP_KEY_FA_LABELS.
*
* Usage:
* const label = EVENT_TYPE_FA_LABELS[event.type]
* ?? resolveStepCompletedFaLabel(event)
* ?? event.type;
*/
// ---------------------------------------------------------------------------
// Blame-phase events (recorded on BlameRequest.history)
// ---------------------------------------------------------------------------
export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
// File creation
FILE_CREATED_BY_REGISTRAR: "پرونده توسط ثبت‌کننده ایجاد شد",
FILE_CREATED_BY_FIELD_EXPERT: "پرونده توسط کارشناس میدانی ایجاد شد",
// Expert link / OTP flow
LINK_SENT: "لینک ارسال شد",
PARTY_OTP_SENT: "کد تأیید ارسال شد",
PARTY_OTP_VERIFIED: "کد تأیید تأیید شد",
PARTY_OTPS_VERIFIED: "کدهای تأیید هر دو طرف تأیید شدند",
SECOND_PARTY_OTP_SENT: "کد تأیید طرف دوم ارسال شد",
SECOND_PARTY_OTP_VERIFIED_ADVANCED: "کد طرف دوم تأیید و پیشرفت انجام شد",
// Confession / accident type
FIRST_BLAME_CONFESSION_SUBMITTED: "اقرار اولیه طرف اول ثبت شد",
CAR_BODY_ACCIDENT_TYPE_SUBMITTED: "نوع تصادف بدنه خودرو ثبت شد",
AUTO_ADVANCED_TO_CAR_BODY_FORM: "پیشرفت خودکار به فرم بدنه خودرو",
AUTO_CONFESSION_SKIPPED: "مرحله اقرار به‌صورت خودکار رد شد",
// First video
FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد",
// Second party invitation
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
// Accident fields / expert in-person completion
ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES:
"اطلاعات تصادف ذخیره و به مرحله امضاها پیشرفت شد",
EXPERT_COMPLETED_CAR_BODY_FORM_V2: "کارشناس فرم بدنه خودرو را تکمیل کرد",
EXPERT_COMPLETED_THIRD_PARTY_FORM_V2: "کارشناس فرم شخص ثالث را تکمیل کرد",
EXPERT_ADDED_LOCATIONS_V2: "کارشناس موقعیت مکانی را ثبت کرد",
EXPERT_UPLOADED_VIDEO_V2: "کارشناس ویدیو را بارگذاری کرد",
EXPERT_UPLOADED_VOICE_V2: "کارشناس صدا را بارگذاری کرد",
// Party rejection / disagreement
PARTY_REJECTED_EXPERT_DECISION: "طرف حساب با نظر کارشناس مخالفت کرد",
PARTIES_DISAGREED_ON_EXPERT_DECISION: "طرفین با نظر کارشناس توافق نکردند",
// Document resend (blame)
BLAME_DOCUMENT_RESEND_STARTED: "درخواست ارسال مجدد مدارک پرونده شروع شد",
BLAME_DOCUMENT_RESEND_CLEARED: "ارسال مجدد مدارک پرونده پاک‌سازی شد",
// Expert assignment (blame)
BLAME_ASSIGNED: "پرونده به کارشناس تخصیص داده شد",
AUTO_ASSIGNED_TO_EXPERT: "پرونده به‌صورت خودکار به کارشناس تخصیص یافت",
// Auto-assignment helpers
READY_FOR_EXPERT_EVALUATION: "پرونده آماده ارزیابی کارشناس شد",
SECOND_PARTY_DESCRIPTION_COMPLETED: "توضیحات طرف دوم تکمیل شد",
// V3 blame steps
V3_PARTY_SIGNATURE_RECORDED: "امضای طرف ثبت شد",
V3_ACCIDENT_FIELDS_SAVED: "اطلاعات تصادف ثبت شد",
V3_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف بارگذاری شد",
V3_PARTY_VOICE_UPLOADED: "صدای طرف بارگذاری شد",
V3_PARTY_LOCATION_SAVED: "موقعیت مکانی طرف ذخیره شد",
V3_PARTY_DESCRIPTION_SAVED: "توضیحات طرف ذخیره شد",
V3_CAR_BODY_ACCIDENT_TYPE_SAVED: "نوع تصادف بدنه خودرو ذخیره شد",
// V5 blame steps
V5_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف (نسخه ۵) بارگذاری شد",
V5_FILE_MAKER_APPROVED: "پرونده‌ساز پرونده را تأیید کرد",
V5_FILE_MAKER_REJECTED: "پرونده‌ساز پرونده را رد کرد",
// ---------------------------------------------------------------------------
// Claim-phase events (recorded on ClaimCase.history)
// ---------------------------------------------------------------------------
// Claim creation
CLAIM_CREATED: "پرونده خسارت ایجاد شد",
// Expert assignment (claim)
CLAIM_ASSIGNED: "پرونده خسارت به کارشناس ارزیابی تخصیص داده شد",
// Expert actions on claim
EXPERT_RESEND_REQUESTED: "کارشناس درخواست ارسال مجدد مدارک داد",
EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد",
IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد",
EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیب‌دیده را به‌روزرسانی کرد",
// Workflow steps (generic)
STEP_COMPLETED: "مرحله تکمیل شد",
V3_STEP_COMPLETED: "مرحله تکمیل شد",
// Documents & media
DOCUMENT_UPLOADED: "مدرک بارگذاری شد",
VIDEO_CAPTURE_UPLOADED: "تصویر ویدیویی بارگذاری شد",
ALL_FACTORS_UPLOADED_PENDING_VALIDATION:
"تمام فاکتورها بارگذاری شدند و در انتظار تأیید هستند",
// User responses
USER_OBJECTION_SUBMITTED: "اعتراض کاربر ثبت شد",
USER_RATING_SUBMITTED: "امتیاز کاربر ثبت شد",
// Owner insurer approval
OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد",
OWNER_REJECTED_INSURER_APPROVAL_PRICING: "صاحب خودرو قیمت‌گذاری بیمه را رد کرد",
OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD:
"صاحب خودرو قطعات قیمت‌گذاری‌شده را امضا کرد و در انتظار بارگذاری فاکتور",
OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS:
"صاحب خودرو قطعات قیمت‌گذاری‌شده را پیش از فاکتور رد کرد",
// Fanavaran sync
FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود",
FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود",
FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: "ارسال زودهنگام خودکار به فناوران موفق بود",
FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: "ارسال زودهنگام خودکار به فناوران ناموفق بود",
FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED:
"ارسال خودکار کارشناسی به فناوران موفق بود",
FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED:
"ارسال خودکار کارشناسی به فناوران ناموفق بود",
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED:
"ارسال خودکار پیوست به فناوران موفق بود",
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED:
"ارسال خودکار پیوست به فناوران ناموفق بود",
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_SUCCEEDED:
"ارسال خودکار پرونده خسارت به فناوران موفق بود",
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_FAILED:
"ارسال خودکار پرونده خسارت به فناوران ناموفق بود",
// V5 claim
V5_HELD_FOR_FILE_MAKER_APPROVAL: "پرونده در انتظار تأیید پرونده‌ساز متوقف شد",
};
// ---------------------------------------------------------------------------
// Persian labels for `metadata.stepKey` inside STEP_COMPLETED events
// ---------------------------------------------------------------------------
export const STEP_KEY_FA_LABELS: Record<string, string> = {
// Claim workflow steps
CLAIM_CREATED: "ایجاد پرونده خسارت",
SELECT_OUTER_PARTS: "انتخاب قطعات بیرونی آسیب‌دیده",
SELECT_OTHER_PARTS: "انتخاب سایر اطلاعات و قطعات",
CAPTURE_PART_DAMAGES: "عکس‌برداری از آسیب‌های قطعات",
UPLOAD_REQUIRED_DOCUMENTS: "بارگذاری مدارک مورد نیاز",
USER_SUBMISSION_COMPLETE: "ارسال اطلاعات توسط کاربر",
USER_EXPERT_RESEND: "ارسال مجدد مدارک توسط کاربر",
EXPERT_DAMAGE_ASSESSMENT: "ارزیابی خسارت توسط کارشناس",
EXPERT_FINAL_REPLY: "پاسخ نهایی کارشناس",
EXPERT_COST_EVALUATION: "ارزیابی هزینه توسط کارشناس",
OWNER_UPLOAD_FACTOR_DOCUMENTS: "بارگذاری فاکتورهای تعمیر توسط مالک",
INSURER_REVIEW: "بررسی توسط بیمه‌گر",
CLAIM_COMPLETED: "پرونده خسارت تکمیل شد",
};
/**
* For `STEP_COMPLETED` and `V3_STEP_COMPLETED` events whose final label
* depends on the `metadata.stepKey` value, this function returns the
* most specific Persian label available.
*/
export function resolveStepCompletedFaLabel(event: {
type: string;
metadata?: any;
}): string | undefined {
if (
event.type !== "STEP_COMPLETED" &&
event.type !== "V3_STEP_COMPLETED"
) {
return undefined;
}
const stepKey: string | undefined = event.metadata?.stepKey;
if (stepKey && STEP_KEY_FA_LABELS[stepKey]) {
return STEP_KEY_FA_LABELS[stepKey];
}
return "مرحله تکمیل شد";
}
/**
* Returns the best Persian label for any timeline event.
*/
export function getEventFaLabel(event: {
type: string;
metadata?: any;
}): string {
return (
resolveStepCompletedFaLabel(event) ??
EVENT_TYPE_FA_LABELS[event.type] ??
event.type
);
}

View File

@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { FanavaranAuditService } from "./fanavaran-audit.service";
import {
FanavaranAuditLog,
FanavaranAuditLogSchema,
} from "./schema/fanavaran-audit-log.schema";
@Module({
imports: [
MongooseModule.forFeature([
{ name: FanavaranAuditLog.name, schema: FanavaranAuditLogSchema },
]),
],
providers: [FanavaranAuditService],
exports: [FanavaranAuditService],
})
export class FanavaranAuditModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError } from "axios";
import { randomBytes } from "node:crypto";
import { Model, Types } from "mongoose";
import {
FanavaranAuditLog,
FanavaranAuditLogDocument,
FanavaranAuditStatus,
FanavaranAuditStep,
} from "./schema/fanavaran-audit-log.schema";
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
export interface RecordFanavaranAuditStepInput {
session: FanavaranAuditSession;
step: FanavaranAuditStep;
status: FanavaranAuditStatus;
requestUrl?: string;
httpStatus?: number;
requestMeta?: Record<string, unknown>;
responseMeta?: Record<string, unknown>;
errorMessage?: string;
errorDetails?: Record<string, unknown>;
durationMs?: number;
}
@Injectable()
export class FanavaranAuditService {
private readonly logger = new Logger(FanavaranAuditService.name);
constructor(
@InjectModel(FanavaranAuditLog.name)
private readonly auditModel: Model<FanavaranAuditLogDocument>,
) {}
generateTrackingCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const suffix = randomBytes(3).toString("hex").toUpperCase();
return `FNV-${date}-${suffix}`;
}
maskNationalCode(nationalCode: string): string {
const trimmed = nationalCode.trim();
if (trimmed.length <= 4) {
return "****";
}
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`;
}
sanitizeErrorDetails(error: unknown): Record<string, unknown> {
if (isAxiosError(error)) {
const data = error.response?.data;
return {
type: "axios",
status: error.response?.status,
statusText: error.response?.statusText,
data:
typeof data === "object" && data !== null
? data
: typeof data === "string"
? data.slice(0, 2000)
: data,
};
}
if (error instanceof Error) {
return { type: "error", name: error.name, message: error.message };
}
return { type: "unknown", value: String(error) };
}
extractErrorMessage(error: unknown): string {
if (isAxiosError(error)) {
const data = error.response?.data as
| { Message?: string; message?: string }
| string
| undefined;
if (typeof data === "string") {
return data;
}
return (
data?.Message ||
data?.message ||
error.message ||
"Fanavaran request failed"
);
}
if (error instanceof Error) {
return error.message;
}
return "Fanavaran request failed";
}
formatErrorWithTrackingCode(message: string, trackingCode?: string): string {
if (!trackingCode) {
return message;
}
return `${message} (trackingCode: ${trackingCode})`;
}
async recordStep(input: RecordFanavaranAuditStepInput): Promise<void> {
try {
await this.auditModel.create({
trackingCode: input.session.trackingCode,
step: input.step,
status: input.status,
clientKey: input.session.clientKey,
source: input.session.source,
...(input.session.claimCaseId
? { claimCaseId: new Types.ObjectId(input.session.claimCaseId) }
: {}),
...(input.session.claimRequestId
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
: {}),
requestUrl: input.requestUrl,
httpStatus: input.httpStatus,
requestMeta: input.requestMeta,
responseMeta: input.responseMeta,
errorMessage: input.errorMessage,
errorDetails: input.errorDetails,
durationMs: input.durationMs,
});
} catch (error) {
this.logger.error(
`Failed to persist Fanavaran audit step ${input.step} (${input.status})`,
error,
);
}
}
async findByTrackingCode(trackingCode: string) {
return this.auditModel
.find({ trackingCode })
.sort({ createdAt: 1 })
.lean()
.exec();
}
}

View File

@@ -0,0 +1,10 @@
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
import { FanavaranAuditSource } from "./schema/fanavaran-audit-log.schema";
export interface FanavaranAuditSession {
trackingCode: string;
clientKey: FanavaranClientKey;
source: FanavaranAuditSource;
claimCaseId?: string;
claimRequestId?: string;
}

View File

@@ -0,0 +1,124 @@
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
export const FANAVARAN_LOOKUP_BASE_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0";
export interface FanavaranRemoteLookupDefinition {
name: string;
url: string;
cacheFile: string;
}
/** Fanavaran API lookups synced per client (Parsian fetches on first call). */
export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
{
name: "accident-causes",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`,
cacheFile: "accident-causes.json",
},
{
name: "accident-report-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`,
cacheFile: "accident-report-type.json",
},
{
name: "vehicle-use-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-use-types`,
cacheFile: "vehicle-use-types.json",
},
{
name: "dmg-pay-method",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-pay-method`,
cacheFile: "dmg-pay-method.json",
},
{
name: "driving-licence-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/driving-licence-types`,
cacheFile: "driving-licence-types.json",
},
{
name: "accident-culprit-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-culprit-type`,
cacheFile: "accident-culprit-type.json",
},
{
name: "inspection-place",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/inspection-place`,
cacheFile: "inspection-place.json",
},
{
name: "drop-amount-status",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/drop-amount-status`,
cacheFile: "drop-amount-status.json",
},
{
name: "car-components",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/car-components`,
cacheFile: "car-components.json",
},
{
name: "accident-level",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-level`,
cacheFile: "accident-level.json",
},
{
name: "expert-status",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/expert-status`,
cacheFile: "expert-status.json",
},
{
name: "vehicle-kinds",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-kinds`,
cacheFile: "vehicle-kinds.json",
},
{
name: "person-role",
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/person-role`,
cacheFile: "person-role.json",
},
{
name: "insurance-corp",
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`,
cacheFile: "insurance-corp.json",
},
{
name: "file-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/file-types`,
cacheFile: "file-types.json",
},
// NEW LOOKUPS ADDED
{
name: "cities",
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/cities`,
cacheFile: "cities.json",
},
{
name: "dmg-case-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-case-type`,
cacheFile: "dmg-case-type.json",
},
{
name: "dmg-history-status",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-case-history-status`,
cacheFile: "dmg-history-status.json",
},
{
name: "provinces",
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/Provinces`,
cacheFile: "provinces.json",
},
];
export const TEJARAT_STATIC_ACCIDENT_FILES = {
accidentReason: "ACCIDENT_REASON.json",
accidentWay: "ACCIDENT_WAY.json",
accidentType: "ACCIDENT_TYPE.json",
} as const;
export function fanavaranLookupCacheDir(clientKey: FanavaranClientKey): string {
return `${process.cwd()}/files/fanavaran-lookups/${clientKey}`;
}
export function tejaratStaticAccidentFilePath(fileName: string): string {
return `${process.cwd()}/src/static/${fileName}`;
}

View File

@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { FanavaranLookupService } from "./fanavaran-lookup.service";
@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
],
providers: [FanavaranLookupService],
exports: [FanavaranLookupService],
})
export class FanavaranLookupModule {}

View File

@@ -0,0 +1,413 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { HttpService } from "@nestjs/axios";
import {
BadGatewayException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { firstValueFrom } from "rxjs";
import { isAxiosError } from "axios";
import {
getFanavaranClientProfile,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import {
FANAVARAN_LOOKUP_BASE_URL,
fanavaranLookupCacheDir,
tejaratStaticAccidentFilePath,
} from "./fanavaran-lookup.config";
@Injectable()
export class FanavaranLookupService {
private readonly logger = new Logger(FanavaranLookupService.name);
private readonly getAppTokenUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
private readonly loginUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
constructor(private readonly httpService: HttpService) {}
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
return join(fanavaranLookupCacheDir(clientKey), fileName);
}
async readCacheFile<T>(
clientKey: FanavaranClientKey,
fileName: string,
): Promise<T | null> {
try {
const content = await readFile(
this.cacheFilePath(clientKey, fileName),
"utf-8",
);
return JSON.parse(content) as T;
} catch {
return null;
}
}
async writeCacheFile(
clientKey: FanavaranClientKey,
fileName: string,
data: unknown,
): Promise<void> {
const dir = fanavaranLookupCacheDir(clientKey);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, fileName),
`${JSON.stringify(data, null, 2)}\n`,
"utf-8",
);
this.logger.log(
`Cached Fanavaran lookup ${fileName} for client ${clientKey}`,
);
}
async readTejaratStaticAccidentFile<T>(fileName: string): Promise<T> {
const cached = await readFile(tejaratStaticAccidentFilePath(fileName), "utf-8");
return JSON.parse(cached) as T;
}
private async getAppToken(config: {
appName: string;
secret: string;
}): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", {
headers: {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
throw new BadGatewayException("Failed to get Fanavaran appToken");
}
return appToken;
}
private async login(
appToken: string,
config: { username: string; password: string },
): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", {
headers: {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
if (!authenticationToken) {
throw new BadGatewayException("Failed to get Fanavaran authenticationToken");
}
return authenticationToken;
}
async fetchFromFanavaran(
clientKey: FanavaranClientKey,
url: string,
): Promise<unknown> {
const profile = getFanavaranClientProfile(clientKey);
try {
const appToken = await this.getAppToken(profile.auth);
const authenticationToken = await this.login(appToken, profile.auth);
this.logger.log(
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
);
const response = await firstValueFrom(
this.httpService.get(url, {
headers: {
authenticationToken,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
"Content-Type": "application/json",
},
timeout: 20000,
}),
);
const dataCount = Array.isArray(response.data)
? response.data.length
: typeof response.data === "object" && response.data !== null
? Object.keys(response.data).length
: 0;
this.logger.log(
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
);
return response.data;
} catch (error) {
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||
error.message
: error instanceof Error
? error.message
: "Fanavaran lookup request failed";
this.logger.error(
`Fanavaran lookup fetch failed for ${clientKey} (${url}): ${message}`,
);
throw new BadGatewayException(String(message));
}
}
async getRemoteLookup(
clientKey: FanavaranClientKey,
url: string,
cacheFile: string,
fallback?: () => Promise<unknown>,
): Promise<unknown> {
const cached = await this.readCacheFile(clientKey, cacheFile);
if (cached !== null) {
return cached;
}
try {
const data = await this.fetchFromFanavaran(clientKey, url);
await this.writeCacheFile(clientKey, cacheFile, data);
return data;
} catch (error) {
if (fallback) {
this.logger.warn(
`Fanavaran lookup fetch failed for ${clientKey}/${cacheFile}; using fallback`,
);
const data = await fallback();
await this.writeCacheFile(clientKey, cacheFile, data);
return data;
}
throw error;
}
}
async inquiryByVin(
clientKey: FanavaranClientKey,
vin: string,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/inquiry-by-vin?vin=${encodeURIComponent(vin)}`;
return this.fetchFromFanavaran(clientKey, url);
}
async myPolicies(
clientKey: FanavaranClientKey,
nationalCode: string,
insuranceLineId: number = 5,
): Promise<unknown> {
const url =
`${FANAVARAN_LOOKUP_BASE_URL}/common/Policies/inquiry-my-policies` +
`?InsuranceLineId=${insuranceLineId}` +
`&NationalCode=${encodeURIComponent(nationalCode)}`;
return this.fetchFromFanavaran(clientKey, url);
}
async thirdPartyPolicyById(
clientKey: FanavaranClientKey,
policyId: number,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/third-party-car-policies/${policyId}`;
return this.fetchFromFanavaran(clientKey, url);
}
async bodyPolicyById(
clientKey: FanavaranClientKey,
policyId: number,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}`;
return this.fetchFromFanavaran(clientKey, url);
}
async vehicleById(
clientKey: FanavaranClientKey,
vehicleId: number,
versionNo: number = 1,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/${vehicleId}?versionno=${versionNo}`;
return this.fetchFromFanavaran(clientKey, url);
}
async customerById(
clientKey: FanavaranClientKey,
customerId: number,
): Promise<unknown> {
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/customers/${customerId}`;
return this.fetchFromFanavaran(clientKey, url);
}
async inquiryByUniqueIdentifier(
clientKey: FanavaranClientKey,
params: {
nationalCode: string;
birthYear: number;
birthMonth: number;
birthDay: number;
},
): Promise<unknown> {
const url =
`${FANAVARAN_LOOKUP_BASE_URL}/common/parties/inquiry-by-unique-identifier` +
`?NationalCode=${encodeURIComponent(params.nationalCode)}` +
`&BirthYear=${params.birthYear}` +
`&BirthMonth=${params.birthMonth}` +
`&BirthDay=${params.birthDay}`;
return this.fetchFromFanavaran(clientKey, url);
}
async resolveInsuranceCorpId(
clientKey: FanavaranClientKey,
): Promise<number | null> {
const caption = process.env.INSURANCE_CORP_ID?.trim();
if (!caption) {
this.logger.warn("resolveInsuranceCorpId: INSURANCE_CORP_ID env not set");
return null;
}
// Check resolved cache first
const cachedId = await this.readCacheFile<number>(clientKey, "insurance-corp-id-resolved.json");
if (cachedId !== null) {
this.logger.log(`resolveInsuranceCorpId: using cached id=${cachedId} for "${caption}"`);
return cachedId;
}
// Try fetching the full list directly from Fanavaran
let companies: unknown;
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`;
try {
companies = await this.fetchFromFanavaran(clientKey, url);
} catch (error) {
this.logger.warn(
`resolveInsuranceCorpId: Fanavaran fetch failed, trying local lookup endpoint`,
);
// Fallback: call our own local lookup endpoint
try {
const localPort = process.env.PORT || 3000;
const response = await firstValueFrom(
this.httpService.get(`http://localhost:${localPort}/lookups/fanavaran/insurance-corp`, {
timeout: 10000,
}),
);
companies = response.data;
} catch (localError) {
this.logger.error(
`resolveInsuranceCorpId: both Fanavaran and local lookup failed`,
);
return null;
}
}
if (!Array.isArray(companies)) {
this.logger.warn(
`resolveInsuranceCorpId: insurance-corp response is not an array, type=${typeof companies}`,
);
return null;
}
this.logger.log(
`resolveInsuranceCorpId: got ${companies.length} companies, searching for "${caption}"`,
);
const normalizedCaption = caption
.replace(/\s+/g, " ")
.toLowerCase()
.trim();
const match = companies.find((c: any) => {
if (c?.IsActive !== 1) return false;
if (typeof c?.Id !== "number") return false;
const cCaption = typeof c?.Caption === "string" ? c.Caption : "";
const normalized = cCaption.replace(/\s+/g, " ").toLowerCase().trim();
return normalized.includes(normalizedCaption) || normalizedCaption.includes(normalized);
});
if (!match) {
this.logger.warn(
`resolveInsuranceCorpId: no active company matching "${caption}". Available: ` +
companies
.filter((c: any) => c?.IsActive === 1)
.map((c: any) => `${c.Caption} (${c.Id})`)
.join(", "),
);
return null;
}
const corpId = match.Id as number;
// Cache both the resolved id and the full list for future use
await this.writeCacheFile(clientKey, "insurance-corp-id-resolved.json", corpId);
await this.writeCacheFile(clientKey, "insurance-corp.json", companies);
this.logger.log(`resolveInsuranceCorpId: resolved id=${corpId} for "${caption}"`);
return corpId;
}
mapFanavaranAccidentCausesToReasonOptions(
causes: unknown,
): { id: number; label: string; fanavaran: number }[] {
if (!Array.isArray(causes)) {
throw new NotFoundException("Fanavaran accident-causes response is invalid");
}
return causes
.filter(
(item) =>
item &&
typeof item === "object" &&
(item as { IsActive?: number }).IsActive === 1 &&
typeof (item as { Id?: unknown }).Id === "number",
)
.map((item) => {
const row = item as { Id: number; Caption?: string };
return {
id: row.Id,
label: row.Caption ?? String(row.Id),
fanavaran: row.Id,
};
});
}
}

View File

@@ -0,0 +1,287 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
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 { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
isFanavaranClientKey,
listFanavaranClientProfiles,
normalizeFanavaranClientKey,
resolveFanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
@ApiTags("fanavaran")
@Controller("v2/fanavaran")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.ADMIN, RoleEnum.FIELD_EXPERT)
export class FanavaranController {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("clients")
@ApiOperation({
summary: "List supported Fanavaran insurance clients",
description:
"Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.",
})
listClients() {
const activeClient = resolveFanavaranClientKey();
return {
activeClient,
clients: listFanavaranClientProfiles().map((profile) => ({
key: profile.key,
defaults: profile.defaults,
isActive: profile.key === activeClient,
})),
};
}
@Get(":client/claim-cases/:claimCaseId/base-claim/preview")
@ApiOperation({
summary: "Preview Fanavaran base claim create payload",
description:
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
@ApiQuery({
name: "debug",
required: false,
description: "When true, returns payload plus mapping debug steps",
})
async preview(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId,
clientKey,
{ debug: debug === "1" || debug === "true" },
);
}
@Post(":client/claim-cases/:claimCaseId/base-claim/submit")
@ApiOperation({
summary: "Submit Fanavaran base claim create request",
description:
"Authenticates with the selected tenant credentials and submits the GEN.03 base claim create request. Stores returned Id as claimId and ClaimNo when present.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async submit(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Body() body?: Record<string, unknown>,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.submitFanavaranV2(
claimCaseId,
clientKey,
body && Object.keys(body).length > 0 ? body : undefined,
);
}
@Get(":client/claim-cases/:claimCaseId/damage-case/preview")
@ApiOperation({
summary: "Preview Fanavaran damage-case payload",
description:
"Builds the GEN.12 damaged vehicle/person case payload without calling Fanavaran. Requires selected damaged parts; submit requires a Fanavaran claimId.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async previewDamageCase(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.previewFanavaranDamageCaseV2(
claimCaseId,
clientKey,
);
}
@Post(":client/claim-cases/:claimCaseId/damage-case/submit")
@ApiOperation({
summary: "Submit Fanavaran damage-case request",
description:
"Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async submitDamageCase(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Body() body?: Record<string, unknown>,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.submitFanavaranDamageCaseV2(
claimCaseId,
clientKey,
body && Object.keys(body).length > 0 ? body : undefined,
);
}
@Get(":client/claim-cases/:claimCaseId/attachments/preview")
@ApiOperation({
summary: "Preview Fanavaran attachment upload plan",
description:
"Lists local required-document and captured damage images that would be sent to GEN.07. Shows which images are already recorded as uploaded to Fanavaran.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async previewAttachments(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.previewFanavaranAttachmentsV2(
claimCaseId,
clientKey,
);
}
@Post(":client/claim-cases/:claimCaseId/attachments/submit")
@ApiOperation({
summary: "Submit missing Fanavaran attachments",
description:
"Uploads every local image that does not already have a recorded successful GEN.07 Fanavaran file upload. Uses one multipart request per image.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async submitAttachments(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.submitFanavaranAttachmentsV2(
claimCaseId,
clientKey,
);
}
@Get(":client/claim-cases/:claimCaseId/expertise/preview")
@ApiOperation({
summary: "Preview Fanavaran expertise payload",
description:
"Builds the GEN.08 expertise payload from the active damage expert reply, price-drop data, and Fanavaran lookup mappings without calling Fanavaran.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async previewExpertise(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.previewFanavaranExpertiseV2(
claimCaseId,
clientKey,
);
}
@Post(":client/claim-cases/:claimCaseId/expertise/submit")
@ApiOperation({
summary: "Submit Fanavaran expertise request",
description:
"Submits the GEN.08 expertise payload for a claim with existing Fanavaran claimId and dmgCaseId. Stores returned Id as local expertiseId.",
})
@ApiParam({
name: "client",
description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"],
})
@ApiParam({
name: "claimCaseId",
description: "Claim case MongoDB ObjectId",
})
async submitExpertise(
@Param("client") client: string,
@Param("claimCaseId") claimCaseId: string,
@Body() body?: Record<string, unknown>,
) {
const clientKey = this.parseClientParam(client);
return await this.claimRequestManagementService.submitFanavaranExpertiseV2(
claimCaseId,
clientKey,
body && Object.keys(body).length > 0 ? body : undefined,
);
}
private parseClientParam(client: string) {
if (!isFanavaranClientKey(client)) {
throw new BadRequestException(
`Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`,
);
}
return normalizeFanavaranClientKey(client);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { FanavaranAuditModule } from "./fanavaran-audit.module";
import { FanavaranController } from "./fanavaran.controller";
@Module({
imports: [ClaimRequestManagementModule, FanavaranAuditModule],
controllers: [FanavaranController],
})
export class FanavaranModule {}

View File

@@ -0,0 +1,88 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument, Types } from "mongoose";
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
export enum FanavaranAuditStep {
GET_APP_TOKEN = "GET_APP_TOKEN",
LOGIN = "LOGIN",
POLICY_INQUIRY = "POLICY_INQUIRY",
BUILD_PAYLOAD = "BUILD_PAYLOAD",
SUBMIT_CLAIM = "SUBMIT_CLAIM",
SUBMIT_DAMAGE_CASE = "SUBMIT_DAMAGE_CASE",
SUBMIT_ATTACHMENT = "SUBMIT_ATTACHMENT",
SUBMIT_EXPERTISE = "SUBMIT_EXPERTISE",
}
export enum FanavaranAuditStatus {
STARTED = "started",
SUCCESS = "success",
FAILURE = "failure",
}
export enum FanavaranAuditSource {
PREVIEW = "preview",
SUBMIT = "submit",
AUTO_SUBMIT = "auto_submit",
LEGACY_SUBMIT = "legacy_submit",
}
@Schema({ collection: "fanavaranAuditLogs", timestamps: true })
export class FanavaranAuditLog {
@Prop({ type: String, required: true, index: true })
trackingCode: string;
@Prop({ type: String, required: true, enum: FanavaranAuditStep, index: true })
step: FanavaranAuditStep;
@Prop({
type: String,
required: true,
enum: FanavaranAuditStatus,
index: true,
})
status: FanavaranAuditStatus;
@Prop({ type: String, required: true, index: true })
clientKey: FanavaranClientKey;
@Prop({
type: String,
required: true,
enum: FanavaranAuditSource,
index: true,
})
source: FanavaranAuditSource;
@Prop({ type: Types.ObjectId, required: false, index: true })
claimCaseId?: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: false, index: true })
claimRequestId?: Types.ObjectId;
@Prop({ type: String, required: false })
requestUrl?: string;
@Prop({ type: Number, required: false })
httpStatus?: number;
@Prop({ type: Object, required: false })
requestMeta?: Record<string, unknown>;
@Prop({ type: Object, required: false })
responseMeta?: Record<string, unknown>;
@Prop({ type: String, required: false })
errorMessage?: string;
@Prop({ type: Object, required: false })
errorDetails?: Record<string, unknown>;
@Prop({ type: Number, required: false })
durationMs?: number;
}
export type FanavaranAuditLogDocument = HydratedDocument<FanavaranAuditLog>;
export const FanavaranAuditLogSchema =
SchemaFactory.createForClass(FanavaranAuditLog);
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });

View File

@@ -32,6 +32,63 @@ export function resolveGuiltyPartyUserId(blame: {
return null; return null;
} }
type BlamePartyRow = {
role?: string;
person?: {
userId?: unknown;
phoneNumber?: string;
clientId?: unknown;
fullName?: string;
nationalCodeOfInsurer?: string;
nationalCodeOfDriver?: string;
insurerLicense?: string;
driverLicense?: string;
driverIsInsurer?: boolean;
birthday?: string;
insurerBirthday?: number;
driverBirthday?: string | null;
};
statement?: { admitsGuilt?: boolean; claimsDamage?: boolean };
insurance?: Record<string, unknown>;
vehicle?: Record<string, unknown>;
location?: { lat?: number; lon?: number };
};
/** Full party row for the damaged vehicle (owner / beneficiary side). */
export function resolveDamagedPartyRow(blame: {
type?: string;
parties?: BlamePartyRow[];
expert?: { decision?: { guiltyPartyId?: unknown } };
blameStatus?: string;
}): BlamePartyRow | null {
const parties = blame?.parties ?? [];
if (!parties.length) return null;
const isCarBody =
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
if (isCarBody) {
return parties.find((p) => p.role === "FIRST") ?? parties[0] ?? null;
}
const guiltyId = resolveGuiltyPartyUserId(blame);
if (!guiltyId) {
return parties.find((p) => p.statement?.claimsDamage === true) ?? null;
}
const damagedParty = parties.find(
(p) =>
p.person?.userId != null && String(p.person.userId) !== String(guiltyId),
);
if (damagedParty) return damagedParty;
return (
parties.find(
(p) => p.person && String(p.person.userId ?? "") !== String(guiltyId),
) ?? null
);
}
/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */ /** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */
export function resolveDamagedPartyPerson(blame: { export function resolveDamagedPartyPerson(blame: {
type?: string; type?: string;

View File

@@ -16,6 +16,12 @@ export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
"damaged_metal_plate", "damaged_metal_plate",
] as const; ] as const;
/** Metal-plate keys that are optional in the V4/V5 FileMaker flow. */
export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
"damaged_metal_plate",
"guilty_metal_plate",
] as const;
export type CapturePhaseSequence = export type CapturePhaseSequence =
| "parts" | "parts"
| "angles" | "angles"
@@ -52,7 +58,7 @@ function isRequiredDocumentUploadedOnClaim(
export function getClaimCaptureProgress( export function getClaimCaptureProgress(
claimCase: any, claimCase: any,
options?: { assumeCapturePhaseDocKey?: string }, options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean },
): ClaimCaptureProgress { ): ClaimCaptureProgress {
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(
@@ -83,6 +89,7 @@ export function getClaimCaptureProgress(
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => { (k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
if (k === options?.assumeCapturePhaseDocKey) return false; if (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k); return !isRequiredDocumentUploadedOnClaim(claimCase, k);
}, },
@@ -111,8 +118,11 @@ export function getClaimCaptureProgress(
}; };
} }
export function isClaimCaptureStepComplete(claimCase: any): boolean { export function isClaimCaptureStepComplete(
const p = getClaimCaptureProgress(claimCase); claimCase: any,
options?: { skipMetalPlate?: boolean },
): boolean {
const p = getClaimCaptureProgress(claimCase, options);
return ( return (
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
); );

View File

@@ -263,8 +263,6 @@ export function buildClaimDetailsV2OwnerGuidance(
shape.mixedFactorAndPrice && shape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt !claim.evaluation?.ownerPricedPartsApproval?.signedAt
) { ) {
const ow = objectionWindowForOwner(claim);
const allowObj = ow.pricingEligible;
return { return {
phaseKey: "SIGN_PRICED_LINES", phaseKey: "SIGN_PRICED_LINES",
headline: "Sign acceptance of priced lines", headline: "Sign acceptance of priced lines",
@@ -278,13 +276,12 @@ export function buildClaimDetailsV2OwnerGuidance(
"Multipart sign + agree + branchId", "Multipart sign + agree + branchId",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */ /** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return { return {
phaseKey: "INSURER_REVIEW_NEEDS_REVISION", phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
headline: "Insurer approval — action pending", headline: "Insurer approval — action pending",
@@ -298,13 +295,12 @@ export function buildClaimDetailsV2OwnerGuidance(
"Sign if prompted by app state", "Sign if prompted by app state",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) { if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return { return {
phaseKey: "FINAL_SIGN_OR_REJECT", phaseKey: "FINAL_SIGN_OR_REJECT",
headline: "Accept or reject final pricing", headline: "Accept or reject final pricing",
@@ -323,8 +319,8 @@ export function buildClaimDetailsV2OwnerGuidance(
"Dispute priced lines before signing", "Dispute priced lines before signing",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
} }

View File

@@ -16,6 +16,8 @@ const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES, ClaimCaseStatus.CAPTURING_PART_DAMAGES,
// V4 split flow: sealed by FileMaker, pending FileReviewer pickup.
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
]); ]);
export function blameCaseStatusToReportBucket(status: string): string { export function blameCaseStatusToReportBucket(status: string): string {

View File

@@ -0,0 +1,8 @@
/** Expert blame/claim review assign + workflow lock TTL (POST assign, PUT lock). */
export const EXPERT_WORKFLOW_LOCK_TTL_MS = 30 * 60 * 1000;
export function expertWorkflowLockExpiredAt(
from: Date = new Date(),
): Date {
return new Date(from.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
}

View File

@@ -0,0 +1,166 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type PDFDocumentType from "pdfkit";
import {
isMostlyAscii,
pdfKitRtlKeyValue,
pdfKitRtlParagraph,
} from "./persian-pdf-text";
// pdfkit publishes CJS (`module.exports = PDFDocument`) without a `.default` export.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const PDFDocument = require("pdfkit") as typeof PDFDocumentType;
const PAGE_WIDTH = 595.28;
const MARGIN = 50;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
const LABEL_COLUMN_WIDTH = CONTENT_WIDTH * 0.44;
const VALUE_COLUMN_WIDTH = CONTENT_WIDTH * 0.5;
const VALUE_COLUMN_X = MARGIN;
const COLON_X = MARGIN + VALUE_COLUMN_WIDTH + 4;
const LABEL_COLUMN_X = COLON_X + 10;
function resolveFontFile(variant: "regular" | "bold"): string {
const file =
variant === "bold" ? "Vazirmatn-Bold.ttf" : "Vazirmatn-Regular.ttf";
const candidates = [
join(process.cwd(), "assets", file),
join(process.cwd(), "assets", "fonts", file),
join(process.cwd(), "dist", "assets", file),
join(process.cwd(), "dist", "assets", "fonts", file),
];
for (const path of candidates) {
if (existsSync(path)) return path;
}
throw new Error(
`Persian PDF font not found (${file}). Place it under assets/ or assets/fonts/.`,
);
}
export type PersianPdfDocument = InstanceType<typeof PDFDocument> & {
addTitle: (text: string) => void;
addSection: (text: string) => void;
addKeyValue: (label: string, value?: string | number | null) => void;
addBlank: (lines?: number) => void;
};
/** Ensure at least `neededPts` of vertical space remain; add a page if not. */
function ensureSpace(
doc: InstanceType<typeof PDFDocument>,
neededPts: number,
): void {
if (doc.y + neededPts > doc.page.maxY()) {
doc.addPage();
}
}
function drawRtlLine(
doc: InstanceType<typeof PDFDocument>,
text: string,
fontSize: number,
bold: boolean,
): void {
const y = doc.y;
doc
.font(bold ? "FaBold" : "FaRegular")
.fontSize(fontSize)
.text(pdfKitRtlParagraph(text), MARGIN, y, {
width: CONTENT_WIDTH,
align: "right",
lineBreak: false,
});
doc.moveDown(fontSize > 12 ? 0.55 : 0.45);
}
function drawKeyValueRow(
doc: InstanceType<typeof PDFDocument>,
label: string,
value: string,
): void {
// Reserve one row height (font 10pt ≈ 14 pts with leading) before drawing.
ensureSpace(doc, 16);
const y = doc.y;
const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value);
if (isMostlyAscii(faValue)) {
doc.font("Helvetica").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
width: VALUE_COLUMN_WIDTH,
align: "left",
lineBreak: false,
});
} else {
doc.font("FaRegular").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
width: VALUE_COLUMN_WIDTH,
align: "left",
lineBreak: false,
});
}
doc.font("Helvetica").fontSize(10).text(":", COLON_X, y, {
lineBreak: false,
});
doc.font("FaRegular").fontSize(10).text(faLabel, LABEL_COLUMN_X, y, {
width: LABEL_COLUMN_WIDTH,
align: "right",
lineBreak: false,
});
doc.moveDown(0.5);
}
export function createPersianPdfDocument(): PersianPdfDocument {
const doc = new PDFDocument({
size: "A4",
margin: MARGIN,
autoFirstPage: true,
}) as PersianPdfDocument;
doc.registerFont("FaRegular", resolveFontFile("regular"));
doc.registerFont("FaBold", resolveFontFile("bold"));
doc.font("FaRegular");
doc.addTitle = (text: string) => {
doc.moveDown(0.2);
drawRtlLine(doc, text, 16, true);
doc.font("FaRegular").fontSize(10);
doc.moveDown(0.15);
};
doc.addSection = (text: string) => {
// Reserve space for the gap + section heading + at least one data row below it,
// so the heading is never stranded alone at the bottom of a page.
ensureSpace(doc, 60);
doc.moveDown(0.5);
drawRtlLine(doc, text, 13, true);
doc.font("FaRegular").fontSize(10);
doc.moveDown(0.1);
};
doc.addKeyValue = (label: string, value?: string | number | null) => {
const v =
value === undefined || value === null || value === ""
? "-"
: String(value);
drawKeyValueRow(doc, label, v);
};
doc.addBlank = (lines = 1) => {
doc.moveDown(lines * 0.35);
};
return doc;
}
export function persianPdfToBuffer(
doc: InstanceType<typeof PDFDocument>,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
doc.end();
});
}

View File

@@ -0,0 +1,40 @@
/** Strip invisible bidi / ZWNJ chars that render as tofu in embedded Arabic fonts. */
export function normalizePersianPdfText(text: string): string {
return text
.replace(/\u200c/g, " ")
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "")
.replace(/\u2014/g, "-")
.replace(/\s+/g, " ")
.trim();
}
/**
* pdfkit renders glyphs strictly left-to-right.
* fontkit already reshapes each word's glyphs into visual (LTR) order,
* but it does NOT reorder words — so a multi-word RTL string like
* "نام صاحب خودرو" has its words placed LTR on the page and reads
* backwards. Reversing the word sequence here makes pdfkit emit the
* words in the correct visual order for a right-to-left PDF reader.
*/
function reverseRtlWords(text: string): string {
if (!/[\u0600-\u06FF]/.test(text)) return text;
return text.split(" ").reverse().join(" ");
}
export function pdfKitRtlKeyValue(
label: string,
value: string,
): { label: string; value: string } {
return {
label: reverseRtlWords(normalizePersianPdfText(label)),
value: reverseRtlWords(normalizePersianPdfText(value)),
};
}
export function pdfKitRtlParagraph(text: string): string {
return reverseRtlWords(normalizePersianPdfText(text));
}
export function isMostlyAscii(text: string): boolean {
return text.length > 0 && !/[\u0600-\u06FF]/.test(text);
}

View File

@@ -0,0 +1,166 @@
type PdfTextRun = {
pageIndex: number;
x: number;
y: number;
fontSize: number;
bold: boolean;
text: string;
};
/** Minimal PDF 1.4 writer (Helvetica) — no external dependencies. */
export class SimplePdfWriter {
private readonly pageWidth = 595.28;
private readonly pageHeight = 841.89;
private readonly marginX = 50;
private readonly marginTop = 50;
private readonly marginBottom = 50;
private runs: PdfTextRun[] = [];
private pageCount = 1;
private cursorY = this.pageHeight - this.marginTop;
private pageIndex = 0;
private ensureSpace(lineHeight: number): void {
if (this.cursorY - lineHeight < this.marginBottom) {
this.pageIndex += 1;
this.pageCount += 1;
this.cursorY = this.pageHeight - this.marginTop;
}
}
private addRun(text: string, fontSize: number, bold = false): void {
const lineHeight = fontSize + 4;
this.ensureSpace(lineHeight);
this.runs.push({
pageIndex: this.pageIndex,
x: this.marginX,
y: this.cursorY,
fontSize,
bold,
text,
});
this.cursorY -= lineHeight;
}
addTitle(text: string): void {
this.addRun(text, 16, true);
this.cursorY -= 4;
}
addSection(text: string): void {
this.cursorY -= 6;
this.addRun(text, 13, true);
this.cursorY -= 2;
}
addLine(text: string): void {
this.addRun(text, 10, false);
}
addKeyValue(label: string, value?: string | number | null): void {
const v =
value === undefined || value === null || value === ""
? "-"
: String(value);
this.addLine(`${label}: ${v}`);
}
addBlank(): void {
this.cursorY -= 8;
}
toBuffer(): Buffer {
const chunkById: string[] = [];
const offsets: number[] = [0];
let size = Buffer.byteLength("%PDF-1.4\n", "utf8");
const addObj = (content: string): number => {
const id = chunkById.length;
offsets.push(size);
const block = `${id} 0 obj\n${content}\nendobj\n`;
chunkById.push(block);
size += Buffer.byteLength(block, "utf8");
return id;
};
chunkById.push("%PDF-1.4\n");
const fontRegularId = addObj(
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
);
const fontBoldId = addObj(
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
);
const contentIds: number[] = [];
for (let p = 0; p < this.pageCount; p++) {
const pageRuns = this.runs.filter((r) => r.pageIndex === p);
const cmds: string[] = [];
for (const run of pageRuns) {
const font = run.bold ? "F2" : "F1";
cmds.push("BT");
cmds.push(`/${font} ${run.fontSize} Tf`);
cmds.push(`1 0 0 1 ${run.x.toFixed(2)} ${run.y.toFixed(2)} Tm`);
cmds.push(`${encodePdfText(run.text)} Tj`);
cmds.push("ET");
}
const stream = cmds.join("\n");
contentIds.push(
addObj(
`<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`,
),
);
}
const pagesKidsToken = "__PAGES_KIDS__";
const pagesId = addObj(
`<< /Type /Pages /Kids [${pagesKidsToken}] /Count ${this.pageCount} >>`,
);
const pageIds: number[] = [];
for (let p = 0; p < this.pageCount; p++) {
pageIds.push(
addObj(
`<< /Type /Page /Parent ${pagesId} 0 R /Resources << /Font << /F1 ${fontRegularId} 0 R /F2 ${fontBoldId} 0 R >> >> /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] /Contents ${contentIds[p]} 0 R >>`,
),
);
}
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
chunkById[pagesId] = chunkById[pagesId].replace(
pagesKidsToken,
pageIds.map((id) => `${id} 0 R`).join(" "),
);
const body = chunkById.join("");
const bodyBuf = Buffer.from(body, "utf8");
const xrefAt = bodyBuf.length;
const xref: string[] = [
`xref\n0 ${offsets.length}\n0000000000 65535 f \n`,
];
for (let i = 1; i < offsets.length; i++) {
xref.push(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`);
}
const trailer = `trailer\n<< /Size ${offsets.length} /Root ${catalogId} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
return Buffer.concat([bodyBuf, Buffer.from(xref.join("") + trailer, "utf8")]);
}
}
/** PDF literal string for Helvetica (WinAnsi) — Latin text only. */
function encodePdfText(input: string): string {
const text = input.normalize("NFC");
const safe = [...text]
.map((ch) => {
const code = ch.charCodeAt(0);
if (code >= 0x20 && code <= 0x7e) return ch;
if (ch === "—" || ch === "") return "-";
if (/\s/.test(ch)) return " ";
return "";
})
.join("")
.replace(/\s+/g, " ")
.trim();
return `(${safe
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")})`;
}

View File

@@ -31,6 +31,8 @@ const CLAIM_USER_PHASE = new Set<string>([
ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES, ClaimCaseStatus.CAPTURING_PART_DAMAGES,
// V4 split flow: FileMaker sealed the file; FileReviewer hasn't picked it up yet.
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
]); ]);
const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([ const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([

View File

@@ -1,8 +1,18 @@
import { Controller, Get } from "@nestjs/common"; import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger"; import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { AuthGuard } from "src/common/auth/guards";
import { LookupsService } from "./lookups.service"; import { LookupsService } from "./lookups.service";
@ApiTags("lookups") @ApiTags("lookups")
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Controller("lookups") @Controller("lookups")
export class LookupsController { export class LookupsController {
constructor(private readonly lookupsService: LookupsService) {} constructor(private readonly lookupsService: LookupsService) {}
@@ -96,9 +106,247 @@ export class LookupsController {
return await this.lookupsService.getAccidentCulpritType(); return await this.lookupsService.getAccidentCulpritType();
} }
@Get("inspection-place")
@ApiOperation({
summary: "Fanavaran GEN.08 inspection place lookup",
description:
"Returns values for expertise payload field InspectionPlaceId from car/code-list/inspection-place.",
})
@ApiOkResponse({
description: "Returns Fanavaran inspection place lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getInspectionPlace() {
return await this.lookupsService.getInspectionPlace();
}
@Get("drop-amount-status")
@ApiOperation({
summary: "Fanavaran GEN.08 drop amount status lookup",
description:
"Returns values for expertise payload field DropAmountStatus from car/code-list/drop-amount-status.",
})
@ApiOkResponse({
description: "Returns Fanavaran drop amount status lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getDropAmountStatus() {
return await this.lookupsService.getDropAmountStatus();
}
@Get("car-components")
@ApiOperation({
summary: "Fanavaran GEN.08 damaged section lookup",
description:
"Returns values for DmgSections[].DmgSectionId from car/base-info/car-components.",
})
@ApiOkResponse({
description: "Returns Fanavaran car component lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getCarComponents() {
return await this.lookupsService.getCarComponents();
}
@Get("accident-level")
@ApiOperation({
summary: "Fanavaran GEN.08 accident level lookup",
description:
"Returns values for DmgSections[].AccidentLevel from car/code-list/accident-level.",
})
@ApiOkResponse({
description: "Returns Fanavaran accident level lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getAccidentLevel() {
return await this.lookupsService.getAccidentLevel();
}
@Get("expert-status")
@ApiOperation({
summary: "Fanavaran GEN.08 expert status lookup",
description:
"Returns values for expertise response field Status from car/code-list/expert-status.",
})
@ApiOkResponse({
description: "Returns Fanavaran expert status lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getExpertStatus() {
return await this.lookupsService.getExpertStatus();
}
@Get("vehicle-kinds")
@ApiOperation({
summary: "Fanavaran GEN.12 vehicle kind lookup",
description:
"Returns values for damage case payload field VehicleKindId from car/base-info/vehicle-kinds.",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle kinds lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getVehicleKinds() {
return await this.lookupsService.getVehicleKinds();
}
@Get("person-role")
@ApiOperation({
summary: "Fanavaran person role lookup",
description:
"Returns person role codes from common/code-list/person-role.",
})
@ApiOkResponse({
description: "Returns Fanavaran person role lookup data",
schema: {
type: "array",
items: {
type: "object",
properties: {
Caption: { type: "string" },
Id: { type: "number" },
IsActive: { type: "number" },
},
},
},
})
async getPersonRole() {
return await this.lookupsService.getPersonRole();
}
@Get("file-types")
@ApiOperation({
summary: "Fanavaran file types lookup",
description:
"Returns file type codes from common/base-info/file-types. Use FileTypeId in attachment uploads.",
})
@ApiOkResponse({
description: "Returns Fanavaran file types lookup data",
schema: {
type: "array",
items: {
type: "object",
properties: {
Caption: { type: "string" },
Id: { type: "number" },
IsActive: { type: "number" },
},
},
},
})
async getFileTypes() {
return await this.lookupsService.getFileTypes();
}
@Get("cities")
@ApiOperation({
summary: "Fanavaran cities lookup",
description: "Returns city codes from common/base-info/cities.",
})
@ApiOkResponse({
description: "Returns Fanavaran cities lookup data",
schema: { type: "array", items: { type: "object" } },
})
async cities() {
return await this.lookupsService.getCities();
}
@Get("provinces")
@ApiOperation({
summary: "Fanavaran provinces lookup",
description: "Returns province codes from common/base-info/provinces.",
})
@ApiOkResponse({
description: "Returns Fanavaran provinces lookup data",
schema: { type: "array", items: { type: "object" } },
})
async provinces() {
return await this.lookupsService.getProvinces();
}
@Get("dmg-case-type")
@ApiOperation({
summary: "Fanavaran damage case type lookup",
description: "Returns damage case type codes from car/code-list/dmg-case-type.",
})
@ApiOkResponse({
description: "Returns Fanavaran damage case type lookup data",
schema: { type: "array", items: { type: "object" } },
})
async dmgCaseType() {
return await this.lookupsService.getDmgCaseType();
}
@Get("dmg-history-status")
@ApiOperation({
summary: "Fanavaran damage history status lookup",
description:
"Returns damage history status codes from car/code-list/dmg-case-history-status.",
})
@ApiOkResponse({
description: "Returns Fanavaran damage history status lookup data",
schema: { type: "array", items: { type: "object" } },
})
async dmgHistoryStatus() {
return await this.lookupsService.getDmgHistoryStatus();
}
@Get("inquiry-by-vin")
@ApiOperation({
summary: "Fanavaran vehicle inquiry by VIN",
description:
"Returns vehicle details from Fanavaran for the given VIN number via car/vehicles/inquiry-by-vin.",
})
@ApiQuery({
name: "vin",
description: "Vehicle Identification Number (VIN)",
example: "IRNKAEK4150012345",
})
@ApiOkResponse({
description: "Returns Fanavaran vehicle inquiry data for the VIN",
schema: { type: "object" },
})
async inquiryByVin(@Query("vin") vin: string) {
return await this.lookupsService.inquiryByVin(vin);
}
@Get("fanavaran")
@ApiOperation({
summary: "List configured Fanavaran remote lookups",
description:
"Returns the lookup names available through /lookups/fanavaran/{lookupName}.",
})
async listFanavaranRemoteLookups() {
return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({
name: lookup.name,
cacheFile: lookup.cacheFile,
url: lookup.url,
}));
}
@Get("fanavaran/:lookupName")
@ApiOperation({
summary: "Fetch a Fanavaran remote lookup by name",
description:
"Generic cached Fanavaran lookup fetcher for configured lookup names, including GEN.08 expertise lookups.",
})
@ApiParam({
name: "lookupName",
description:
"Configured Fanavaran lookup name, for example inspection-place, drop-amount-status, car-components, accident-level, expert-status",
})
@ApiOkResponse({
description: "Returns cached or live Fanavaran lookup data",
schema: { type: "array", items: { type: "object" } },
})
async getFanavaranRemoteLookup(@Param("lookupName") lookupName: string) {
return await this.lookupsService.getClientRemoteLookup(lookupName);
}
@Get("accident-way") @Get("accident-way")
@ApiOkResponse({ @ApiOkResponse({
description: "Returns accident way options for the add-accident-fields step", description:
"Returns accident way options for the add-accident-fields step",
schema: { schema: {
type: "array", type: "array",
items: { items: {
@@ -163,6 +411,7 @@ export class LookupsController {
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
client: { type: "string", example: "tejaratno" },
accidentWay: { accidentWay: {
type: "array", type: "array",
items: { items: {
@@ -203,5 +452,75 @@ export class LookupsController {
async getAccidentFields() { async getAccidentFields() {
return await this.lookupsService.getAccidentFields(); return await this.lookupsService.getAccidentFields();
} }
}
@Get("my-policies")
@ApiOperation({
summary: "My policies inquiry",
description:
"Returns the list of policies for the given national code via common/Policies/inquiry-my-policies.",
})
@ApiQuery({
name: "nationalCode",
description: "National code of the insured person",
example: "0012345678",
})
@ApiQuery({
name: "insuranceLineId",
description: "Insurance line ID (default: 5 for car)",
required: false,
example: 5,
})
@ApiOkResponse({
description: "Returns list of policies",
schema: { type: "array", items: { type: "object" } },
})
async myPolicies(
@Query("nationalCode") nationalCode: string,
@Query("insuranceLineId") insuranceLineId?: number,
) {
return await this.lookupsService.myPolicies(
nationalCode,
insuranceLineId ?? 5,
);
}
@Get("third-party-policy/:policyId")
@ApiOperation({
summary: "Third-party car policy inquiry by policy ID",
description:
"Returns third-party car insurance policy details for the given policy ID via car/third-party-car-policies/{policyId}.",
})
@ApiParam({
name: "policyId",
description: "Fanavaran policy ID",
example: 12345,
})
@ApiOkResponse({
description: "Returns third-party policy details",
schema: { type: "object" },
})
async thirdPartyPolicyById(
@Param("policyId", ParseIntPipe) policyId: number,
) {
return await this.lookupsService.thirdPartyPolicyById(policyId);
}
@Get("body-policy/:policyId")
@ApiOperation({
summary: "Vehicle hull (body) policy inquiry by policy ID",
description:
"Returns vehicle hull (body) insurance policy details for the given policy ID via car/vehicle-hull-policies/{policyId}.",
})
@ApiParam({
name: "policyId",
description: "Fanavaran policy ID",
example: 12345,
})
@ApiOkResponse({
description: "Returns body policy details",
schema: { type: "object" },
})
async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) {
return await this.lookupsService.bodyPolicyById(policyId);
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema"; import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema";
import { LookupsController } from "./lookups.controller"; import { LookupsController } from "./lookups.controller";
@@ -7,6 +8,7 @@ import { LookupsService } from "./lookups.service";
@Module({ @Module({
imports: [ imports: [
FanavaranLookupModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: LookupModel.name, schema: LookupSchema }, { name: LookupModel.name, schema: LookupSchema },
]), ]),

View File

@@ -1,15 +1,52 @@
import { readFile } from "node:fs/promises";
import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config";
import {
FANAVARAN_REMOTE_LOOKUPS,
type FanavaranRemoteLookupDefinition,
TEJARAT_STATIC_ACCIDENT_FILES,
} from "src/fanavaran/fanavaran-lookup.config";
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service";
import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
type TejaratAccidentReasonRow = {
id: number;
persianLabel: string;
fanavaranID: number;
};
type TejaratAccidentLabelRow = {
id: number;
persianLabel: string;
};
@Injectable() @Injectable()
export class LookupsService { export class LookupsService {
private readonly logger = new Logger(LookupsService.name); private readonly logger = new Logger(LookupsService.name);
constructor(private readonly lookupDbService: LookupDbService) {} constructor(
private readonly lookupDbService: LookupDbService,
private readonly fanavaranLookupService: FanavaranLookupService,
) {}
private activeClientKey() {
return resolveFanavaranClientKey();
}
listFanavaranRemoteLookups(): FanavaranRemoteLookupDefinition[] {
return FANAVARAN_REMOTE_LOOKUPS;
}
private findRemoteLookup(name: string) {
const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name);
if (!lookup) {
throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`);
}
return lookup;
}
/** /**
* Returns stored `response` for a lookup document by `name` in the lookups collection. * Returns stored `response` for a lookup document by `name` in the lookups collection.
* Legacy Tejarat seed data — used as fallback when live fetch is unavailable.
*/ */
async getLookup(lookupName: string): Promise<any> { async getLookup(lookupName: string): Promise<any> {
const doc = await this.lookupDbService.findOne({ name: lookupName }); const doc = await this.lookupDbService.findOne({ name: lookupName });
@@ -20,42 +57,244 @@ export class LookupsService {
return doc.response; return doc.response;
} }
async getClientRemoteLookup(lookupName: string): Promise<unknown> {
const clientKey = this.activeClientKey();
const definition = this.findRemoteLookup(lookupName);
return this.fanavaranLookupService.getRemoteLookup(
clientKey,
definition.url,
definition.cacheFile,
clientKey === "tejaratno"
? async () => this.getLookup(lookupName)
: undefined,
);
}
async getAccidentCauses(): Promise<any> { async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes"); return await this.getClientRemoteLookup("accident-causes");
} }
async getAccidentReportType(): Promise<any> { async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type"); return await this.getClientRemoteLookup("accident-report-type");
} }
async getVehicleUseTypes(): Promise<any> { async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types"); return await this.getClientRemoteLookup("vehicle-use-types");
} }
async getDmgPayMethod(): Promise<any> { async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method"); return await this.getClientRemoteLookup("dmg-pay-method");
} }
async getDrivingLicenceTypes(): Promise<any> { async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types"); return await this.getClientRemoteLookup("driving-licence-types");
} }
async getAccidentCulpritType(): Promise<any> { async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type"); return await this.getClientRemoteLookup("accident-culprit-type");
}
async getInspectionPlace(): Promise<any> {
return await this.getClientRemoteLookup("inspection-place");
}
async getDropAmountStatus(): Promise<any> {
return await this.getClientRemoteLookup("drop-amount-status");
}
async getCarComponents(): Promise<any> {
return await this.getClientRemoteLookup("car-components");
}
async getAccidentLevel(): Promise<any> {
return await this.getClientRemoteLookup("accident-level");
}
async getExpertStatus(): Promise<any> {
return await this.getClientRemoteLookup("expert-status");
}
async getVehicleKinds(): Promise<any> {
return await this.getClientRemoteLookup("vehicle-kinds");
}
async getPersonRole(): Promise<any> {
return await this.getClientRemoteLookup("person-role");
}
async getFileTypes(): Promise<any> {
return await this.getClientRemoteLookup("file-types");
}
async getCities(): Promise<any> {
return await this.getClientRemoteLookup("cities");
}
async getProvinces(): Promise<any> {
return await this.getClientRemoteLookup("provinces");
}
async getDmgCaseType(): Promise<any> {
return await this.getClientRemoteLookup("dmg-case-type");
}
async getDmgHistoryStatus(): Promise<any> {
return await this.getClientRemoteLookup("dmg-history-status");
}
async inquiryByVin(vin: string): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.inquiryByVin(clientKey, vin);
}
async myPolicies(
nationalCode: string,
insuranceLineId: number = 5,
): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.myPolicies(
clientKey,
nationalCode,
insuranceLineId,
);
}
async mapPolicyDetails(policy: any): Promise<any> {
if (!policy || typeof policy !== "object") {
return policy;
}
const mappedPolicy = { ...policy };
// 1. Map PreviousInsuranceCorpId and TransferorInsuranceCorpId
try {
const companies = (await this.getClientRemoteLookup("insurance-corp")) as any[];
if (Array.isArray(companies)) {
if (typeof policy.PreviousInsuranceCorpId === "number") {
const match = companies.find((c) => c.Id === policy.PreviousInsuranceCorpId);
if (match) {
mappedPolicy.PreviousInsuranceCorpName = match.Caption;
}
}
if (typeof policy.TransferorInsuranceCorpId === "number") {
const match = companies.find((c) => c.Id === policy.TransferorInsuranceCorpId);
if (match) {
mappedPolicy.TransferorInsuranceCorpName = match.Caption;
}
}
}
} catch (e) {
this.logger.warn(`Failed to map insurance-corp lookups: ${e.message}`);
}
// 2. Map PolicyUsageTypeId
try {
const useTypes = (await this.getClientRemoteLookup("vehicle-use-types")) as any[];
if (Array.isArray(useTypes) && typeof policy.PolicyUsageTypeId === "number") {
const match = useTypes.find((t) => t.Id === policy.PolicyUsageTypeId);
if (match) {
mappedPolicy.PolicyUsageTypeName = match.Caption;
}
}
} catch (e) {
this.logger.warn(`Failed to map vehicle-use-types lookups: ${e.message}`);
}
return mappedPolicy;
}
async thirdPartyPolicyById(policyId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
const policy = await this.fanavaranLookupService.thirdPartyPolicyById(clientKey, policyId);
return this.mapPolicyDetails(policy);
}
async bodyPolicyById(policyId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
const policy = await this.fanavaranLookupService.bodyPolicyById(clientKey, policyId);
return this.mapPolicyDetails(policy);
}
async vehicleById(vehicleId: number, versionNo: number = 1): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.vehicleById(clientKey, vehicleId, versionNo);
}
async customerById(customerId: number): Promise<unknown> {
const clientKey = this.activeClientKey();
return this.fanavaranLookupService.customerById(clientKey, customerId);
} }
async getAccidentWay(): Promise<{ id: number; label: string }[]> { async getAccidentWay(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse( const clientKey = this.activeClientKey();
await readFile("src/static/ACCIDENT_WAY.json", "utf-8"), const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay;
);
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentLabelRow[]
>(clientKey, fileName);
const raw =
cached ??
(clientKey === "tejaratno"
? await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName)
: null);
if (!raw) {
this.logger.warn(
`No accident-way lookup for client ${clientKey}; falling back to tejarat static file`,
);
const fallback =
await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName);
return fallback.map((item) => ({
id: item.id,
label: item.persianLabel,
}));
}
return raw.map((item) => ({ id: item.id, label: item.persianLabel })); return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
} }
async getAccidentReason(): Promise< async getAccidentReason(): Promise<
{ id: number; label: string; fanavaran: number }[] { id: number; label: string; fanavaran: number }[]
> { > {
const raw: { id: number; persianLabel: string; fanavaranID: number }[] = const clientKey = this.activeClientKey();
JSON.parse(await readFile("src/static/ACCIDENT_REASON.json", "utf-8"));
if (clientKey === "parsian") {
const cacheFile = "accident-reason-options.json";
const cached = await this.fanavaranLookupService.readCacheFile<
{ id: number; label: string; fanavaran: number }[]
>(clientKey, cacheFile);
if (cached) {
return cached;
}
const causes = await this.getAccidentCauses();
const mapped =
this.fanavaranLookupService.mapFanavaranAccidentCausesToReasonOptions(
causes,
);
await this.fanavaranLookupService.writeCacheFile(
clientKey,
cacheFile,
mapped,
);
return mapped;
}
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentReason;
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentReasonRow[]
>(clientKey, fileName);
const raw =
cached ??
(await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentReasonRow[]
>(fileName));
return raw.map((item) => ({ return raw.map((item) => ({
id: item.id, id: item.id,
label: item.persianLabel, label: item.persianLabel,
@@ -64,9 +303,34 @@ export class LookupsService {
} }
async getAccidentType(): Promise<{ id: number; label: string }[]> { async getAccidentType(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse( const clientKey = this.activeClientKey();
await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"), const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentType;
);
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentLabelRow[]
>(clientKey, fileName);
const raw =
cached ??
(clientKey === "tejaratno"
? await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName)
: null);
if (!raw) {
this.logger.warn(
`No accident-type lookup for client ${clientKey}; falling back to tejarat static file`,
);
const fallback =
await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName);
return fallback.map((item) => ({
id: item.id,
label: item.persianLabel,
}));
}
return raw.map((item) => ({ id: item.id, label: item.persianLabel })); return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
} }
@@ -74,12 +338,18 @@ export class LookupsService {
accidentWay: { id: number; label: string }[]; accidentWay: { id: number; label: string }[];
accidentReason: { id: number; label: string; fanavaran: number }[]; accidentReason: { id: number; label: string; fanavaran: number }[];
accidentType: { id: number; label: string }[]; accidentType: { id: number; label: string }[];
client: string;
}> { }> {
const [accidentWay, accidentReason, accidentType] = await Promise.all([ const [accidentWay, accidentReason, accidentType] = await Promise.all([
this.getAccidentWay(), this.getAccidentWay(),
this.getAccidentReason(), this.getAccidentReason(),
this.getAccidentType(), this.getAccidentType(),
]); ]);
return { accidentWay, accidentReason, accidentType }; return {
client: this.activeClientKey(),
accidentWay,
accidentReason,
accidentType,
};
} }
} }

View File

@@ -0,0 +1,154 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
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 { RequestManagementService } from "./request-management.service";
import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto";
/**
* V6 call-center blame API.
*
* A call-center agent takes the guilty party's details over the phone
* (plate, national code, birthday), runs the insurance inquiry, then sends
* the blame link via SMS. The user (guilty party) opens the link and
* completes the form through the standard v2 user flow — except the
* initial-form / inquiry step is skipped (`skipInitialFormStep=true`) because
* the agent already did it.
*
* For THIRD_PARTY files: only the guilty party's data is collected here.
* The damaged party sees their own portion of the page and fills their info
* as normal after the blame link is opened.
*/
@ApiTags("call-center-blame (v6)")
@Controller("v6/call-center-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.CALL_CENTER)
export class CallCenterBlameV6Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
) {}
@Get("my-files")
@ApiOperation({ summary: "[V6] List blame files created by this call-center agent" })
@ApiResponse({ status: 200, description: "List of blame files started by this agent" })
getMyFiles(@CurrentUser() agent: any) {
return this.requestManagementService.getMyCallCenterFilesV6(agent);
}
@Post("create")
@ApiOperation({
summary: "[V6] Create a call-centerinitiated blame file",
description:
"Creates a LINK blame file. The call-center agent collects the guilty " +
"party's plate + national-code over the phone, calls run-inquiry to store " +
"the results, then calls send-link to SMS the blame URL to the user. " +
"The user opens the link and fills the form via the normal v2 flow; the " +
"initial-form/inquiry step is automatically skipped because the agent " +
"already ran it.",
})
@ApiBody({
schema: {
type: "object",
required: ["type"],
properties: {
type: {
type: "string",
enum: ["THIRD_PARTY", "CAR_BODY"],
example: "THIRD_PARTY",
},
},
},
})
createFile(
@CurrentUser() agent: any,
@Body("type") type: "THIRD_PARTY" | "CAR_BODY",
) {
return this.requestManagementService.createCallCenterInitiatedBlameV6(agent, { type });
}
@Post("run-inquiry/:requestId")
@ApiOperation({
summary: "[V6] Run plate + insurance inquiry for the guilty party",
description:
"Call after `create`. The agent supplies the plate and national-code data " +
"collected from the caller. Plate + third-party block inquiry is executed " +
"and the results are stored on the blame document. " +
"For THIRD_PARTY files only the guilty party (first party) is inquired here; " +
"the damaged party's inquiry is handled later by the user via the link. " +
"Sheba (IBAN) is not collected here — the user adds it themselves.",
})
@ApiParam({ name: "requestId", description: "Blame request ID from `create`" })
@ApiBody({ type: RunCallCenterInquiryV6Dto })
runInquiry(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
@Body() dto: RunCallCenterInquiryV6Dto,
) {
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
}
@Post("send-link/:requestId")
@ApiOperation({
summary: "[V6] Send blame link to the guilty party via SMS",
description:
"Call after `run-inquiry`. Provide the guilty party's phone number; " +
"the service registers the user if needed, stores them as the first party, " +
"and sends the blame invite link via SMS. The user opens the link and " +
"completes the blame form via the standard v2 user flow (initial-form step skipped).",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
schema: {
type: "object",
required: ["phoneNumber"],
properties: {
phoneNumber: {
type: "string",
example: "09121234567",
description: "Mobile number of the guilty party",
},
},
},
})
sendLink(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
@Body("phoneNumber") phoneNumber: string,
) {
return this.requestManagementService.sendCallCenterLinkV6(agent, requestId, { phoneNumber });
}
@Get("blame/:requestId")
@ApiOperation({
summary: "[V6] Get a single blame file created by this agent",
description:
"Returns the current status, workflow step, and party data for one blame " +
"file started by this call-center agent. Useful for checking whether the " +
"user has opened the link and progressed through the form.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
getBlame(
@CurrentUser() agent: any,
@Param("requestId") requestId: string,
) {
return this.requestManagementService.getCallCenterBlameDetailV6(agent, requestId);
}
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
@@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 {
imGuilty?: boolean; imGuilty?: boolean;
} }
/**
* V2 initial-form step submitted with a VIN/chassis number instead of a plate.
* All identity and license fields from {@link AddPlateDto} are preserved; only
* `plate` is replaced by `vin` (the 17-character chassis / VIN string).
*/
export class InitialFormVinDto {
@ApiProperty({
type: String,
required: true,
description: "17-character VIN / chassis number (شماره شاسی)",
example: "NAAM01E15HK123456",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfInsurer: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfDriver: string;
@ApiProperty({ type: String, required: true })
insurerLicense: string;
@ApiProperty({ type: String, required: true })
driverLicense: string;
@ApiProperty({ type: Boolean, required: true })
driverIsInsurer: boolean;
@ApiProperty({ type: Boolean, required: true, default: false })
isNewCar: boolean;
@ApiProperty({ type: Boolean, required: true })
userNoCertificate: boolean;
@ApiProperty({ type: Number, required: true })
insurerBirthday: number;
@ApiPropertyOptional({ type: String, required: false })
driverBirthday: string | null;
}
// export class DocsOfThisFile { // export class DocsOfThisFile {
// @ApiProperty() // @ApiProperty()
// firstPartyFile?: FirstPartyFileDto; // firstPartyFile?: FirstPartyFileDto;

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsOptional, IsString } from "class-validator"; import { IsBoolean, IsEnum, IsOptional } from "class-validator";
import { CreationMethod } from "../entities/schema/request-management.schema"; import { CreationMethod } from "../entities/schema/request-management.schema";
export class CreateExpertInitiatedFileDto { export class CreateExpertInitiatedFileDto {
@@ -18,4 +18,17 @@ export class CreateExpertInitiatedFileDto {
}) })
@IsEnum(CreationMethod) @IsEnum(CreationMethod)
creationMethod: CreationMethod; creationMethod: CreationMethod;
/**
* V5 only. When true the resulting claim will require FileMaker approval
* before fanavaran submission. V4 files must leave this unset (or false).
*/
@ApiPropertyOptional({
description:
"V5 only — when true the claim requires FileMaker approval before fanavaran submission.",
example: false,
})
@IsBoolean()
@IsOptional()
requiresFileMakerApproval?: boolean;
} }

View File

@@ -0,0 +1,85 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
class PlateV6Dto {
@ApiProperty({ example: "44", description: "Left two digits" })
@IsString()
@IsNotEmpty()
leftDigits: string;
@ApiProperty({ example: "ب", description: "Center alphabet letter" })
@IsString()
@IsNotEmpty()
centerAlphabet: string;
@ApiProperty({ example: "111", description: "Center three digits" })
@IsString()
@IsNotEmpty()
centerDigits: string;
@ApiProperty({ example: "22", description: "Right two digits (Iran region code)" })
@IsString()
@IsNotEmpty()
ir: string;
}
/**
* Inquiry body for the V6 call-center flow.
* Same as V3 but without `sheba` — the user adds their own IBAN later via the link.
*/
export class RunCallCenterInquiryV6Dto {
@ApiProperty({
type: PlateV6Dto,
description: "Plate segments — Tejarat block / third-party inquiry.",
})
@ValidateNested()
@Type(() => PlateV6Dto)
plate: PlateV6Dto;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({
example: 13780624,
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
})
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
@@ -89,3 +90,66 @@ export class RunInquiriesV3Dto {
@IsString() @IsString()
insurerLicense?: string; insurerLicense?: string;
} }
/**
* Body for `POST run-inquiries-vin/:requestId`.
* Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`.
* First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only).
*/
export class RunInquiriesVinV3Dto {
@ApiProperty({
example: "NAAM01E15HK123456",
description: "17-character VIN / chassis number (شماره شاسی)",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
@IsString()
@IsNotEmpty()
nationalCodeOfInsurer: string;
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
@IsString()
@IsNotEmpty()
nationalCodeOfDriver: string;
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
@IsBoolean()
driverIsInsurer: boolean;
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
insurerBirthday: number | string;
@ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." })
@IsOptional()
driverBirthday?: number | string | null;
@ApiPropertyOptional({
example: "IR123456789012345678901234",
description:
"Sheba (IBAN). Required on the damaged party call (CAR_BODY first call; THIRD_PARTY second call).",
})
@IsOptional()
@IsString()
sheba?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Driver license (required when driverIsInsurer is false).",
})
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional({
example: "123456789",
description: "Insurer license (required when driverIsInsurer is true).",
})
@IsOptional()
@IsString()
insurerLicense?: string;
}

View File

@@ -23,7 +23,9 @@ export class BlameRequestDbService {
return this.blameRequestModel.findOne(filter); return this.blameRequestModel.findOne(filter);
} }
async findById(id: string | Types.ObjectId): Promise<BlameRequestDocument | null> { async findById(
id: string | Types.ObjectId,
): Promise<BlameRequestDocument | null> {
return this.blameRequestModel.findById(id); return this.blameRequestModel.findById(id);
} }
@@ -72,5 +74,11 @@ export class BlameRequestDbService {
: this.blameRequestModel.find(filter); : this.blameRequestModel.find(filter);
return query.exec() as Promise<BlameRequestDocument[]>; return query.exec() as Promise<BlameRequestDocument[]>;
} }
}
async deleteMany(
filter: FilterQuery<BlameRequest>,
): Promise<{ deletedCount: number }> {
const result = await this.blameRequestModel.deleteMany(filter);
return { deletedCount: result.deletedCount };
}
}

View File

@@ -109,6 +109,48 @@ export class BlameRequest {
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ /** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
@Prop({ type: String, enum: FilledBy }) @Prop({ type: String, enum: FilledBy })
filledBy?: FilledBy; filledBy?: FilledBy;
/**
* True when this file was created by a FileMaker (V4 split flow).
* These files are sealed by the FileMaker and completed by a FileReviewer.
*/
@Prop({ default: false })
isMadeByFileMaker?: boolean;
/**
* V5 only. When true the resulting claim requires FileMaker approval before
* fanavaran submission. V4 files never set this; it is written at creation
* time by the V5 controller and propagated to the linked claim.
*/
@Prop({ default: false })
requiresFileMakerApproval?: boolean;
/**
* The FileReviewer who claimed this file for completion (V4 flow only).
* Set when a FileReviewer calls assign on this file; enforces one-reviewer-per-file.
*/
@Prop({ type: Types.ObjectId })
assignedFileReviewerId?: Types.ObjectId;
/**
* V6 call-center flow: true when this blame was started by a call-center agent
* on behalf of the guilty party who called in.
*/
@Prop({ default: false })
callCenterInitiated?: boolean;
/**
* V6 call-center flow: the call-center agent who created this file.
*/
@Prop({ type: Types.ObjectId })
initiatedByCallCenterId?: Types.ObjectId;
/**
* V6 call-center flow: when true the user-side blame page should skip the
* inquiry / initial-form step (the call-center agent already ran the inquiry).
*/
@Prop({ default: false })
skipInitialFormStep?: boolean;
} }
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;

View File

@@ -41,6 +41,10 @@ export class Person {
@Prop({ type: String }) @Prop({ type: String })
driverBirthday?: string | null; driverBirthday?: string | null;
/** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */
@Prop({ type: Number })
fanavaranDriverId?: number;
} }
export const PersonSchema = SchemaFactory.createForClass(Person); export const PersonSchema = SchemaFactory.createForClass(Person);

View File

@@ -42,7 +42,7 @@ export class Workflow {
@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;
@@ -50,7 +50,7 @@ export class Workflow {
lockedBy?: ActorLock; lockedBy?: ActorLock;
/** /**
* First expert who called review assign; kept after the 15m workflow lock expires * First expert who called review assign; kept after the 30m workflow 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: ActorLockSchema }) @Prop({ type: ActorLockSchema })

View File

@@ -31,7 +31,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import { import {
CarBodyFormDto, CarBodyFormDto,
DescriptionDto, DescriptionDto,
@@ -45,9 +45,7 @@ import {
SelectOuterPartsV2Dto, SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto, SelectOuterPartsV2ResponseDto,
} from "src/claim-request-management/dto/select-outer-parts-v2.dto"; } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto";
SelectOtherPartsV2ResponseDto,
} from "src/claim-request-management/dto/select-other-parts-v2.dto";
import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto"; import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto";
import { import {
UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2Dto,
@@ -117,7 +115,8 @@ export class ExpertInitiatedBlameV3MirrorController {
@ApiBody({ type: SendPartyOtpDto }) @ApiBody({ type: SendPartyOtpDto })
@ApiOperation({ @ApiOperation({
summary: "Send OTP to one party", summary: "Send OTP to one party",
description: "Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).", description:
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
}) })
async sendPartyOtp( async sendPartyOtp(
@CurrentUser() expert: any, @CurrentUser() expert: any,
@@ -139,7 +138,11 @@ export class ExpertInitiatedBlameV3MirrorController {
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpDto, @Body() dto: VerifyPartyOtpDto,
) { ) {
return this.requestManagementService.verifyPartyOtpV2(expert, requestId, dto); return this.requestManagementService.verifyPartyOtpV2(
expert,
requestId,
dto,
);
} }
@Post("car-body-form/:requestId") @Post("car-body-form/:requestId")
@@ -178,10 +181,29 @@ export class ExpertInitiatedBlameV3MirrorController {
return this.requestManagementService.runInquiriesV3(requestId, expert, dto); return this.requestManagementService.runInquiriesV3(requestId, expert, dto);
} }
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V3)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(requestId, expert, dto);
}
@Post("add-detail-location/:requestId") @Post("add-detail-location/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto }) @ApiBody({ type: LocationDto })
@ApiOperation({ summary: "Add location for current party (partyRole FIRST or SECOND)" }) @ApiOperation({
summary: "Add location for current party (partyRole FIRST or SECOND)",
})
async addLocation( async addLocation(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@Body() body: LocationDto, @Body() body: LocationDto,
@@ -400,38 +422,42 @@ export class ExpertInitiatedBlameV3MirrorController {
@Patch("select-other-parts/:claimRequestId") @Patch("select-other-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" }) @ApiParam({ name: "claimRequestId", 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, `v3-other-parts-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({ @ApiOperation({
summary: "Select other damaged parts (V3)", summary: "Select other damaged parts (V3)",
description: description:
"Other parts only — sheba and national code were collected during run-inquiries. Optional car green card file.", "Other parts only — sheba and national code were collected during run-inquiries. Optional car green card file.",
}) })
@ApiBody({ @ApiBody({
description: "Other parts selection. Bank info is already on the claim from run-inquiries.", description:
"Other parts selection. Bank info is already on the claim from run-inquiries.",
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
otherParts: { otherParts: {
oneOf: [ oneOf: [
{ type: "array", items: { type: "string", enum: ["engine", "suspension", "brake_system", "electrical", "radiator", "transmission", "exhaust", "headlight", "taillight", "mirror", "glass"] } }, {
type: "array",
items: {
type: "string",
enum: [
"engine",
"suspension",
"brake_system",
"electrical",
"radiator",
"transmission",
"exhaust",
"headlight",
"taillight",
"mirror",
"glass",
],
},
},
{ type: "string", description: "JSON string array for multipart" }, { type: "string", description: "JSON string array for multipart" },
], ],
example: ["engine", "suspension"], example: ["engine", "suspension"],
}, },
file: { type: "string", format: "binary", description: "Optional car green card" },
}, },
}, },
}) })
@@ -439,17 +465,12 @@ export class ExpertInitiatedBlameV3MirrorController {
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV3Dto, @Body() body: SelectOtherPartsV3Dto,
@CurrentUser() expert: any, @CurrentUser() expert: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
if (file) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
}
return this.claimRequestManagementService.selectOtherPartsV3( return this.claimRequestManagementService.selectOtherPartsV3(
claimRequestId, claimRequestId,
body, body,
expert.sub, expert.sub,
expert, expert,
file,
); );
} }

View File

@@ -1,6 +1,5 @@
import { extname } from "node:path"; import { extname } from "node:path";
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Param, Param,
@@ -28,6 +27,7 @@ import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { InitialFormVinDto } from "./dto/create-request-management.dto";
import { import {
BlameConfessionDtoV2, BlameConfessionDtoV2,
CarBodyFormDto, CarBodyFormDto,
@@ -35,6 +35,7 @@ import {
LocationDto, LocationDto,
} from "./dto/create-request-management.dto"; } from "./dto/create-request-management.dto";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { CreationMethod } from "./entities/schema/request-management.schema";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto"; import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto"; import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
@@ -76,17 +77,17 @@ export class ExpertInitiatedBlameMirrorController {
@ApiOperation({ @ApiOperation({
summary: "[Expert mirror] Create expert-initiated blame file", summary: "[Expert mirror] Create expert-initiated blame file",
description: description:
"Use creationMethod=IN_PERSON for the on-site flow. Creates a BlameRequest owned by the parties but filled by the expert.", "Creates an IN_PERSON blame file filled by the expert on behalf of both parties. creationMethod is always forced to IN_PERSON regardless of what is sent.",
}) })
@ApiBody({ type: CreateExpertInitiatedFileDto }) @ApiBody({ type: CreateExpertInitiatedFileDto })
async create( async create(
@CurrentUser() expert: any, @CurrentUser() expert: any,
@Body() dto: CreateExpertInitiatedFileDto, @Body() dto: CreateExpertInitiatedFileDto,
) { ) {
return this.requestManagementService.createExpertInitiatedBlameV2( return this.requestManagementService.createExpertInitiatedBlameV2(expert, {
expert, ...dto,
dto, creationMethod: CreationMethod.IN_PERSON,
); });
} }
@Post("send-link/:requestId") @Post("send-link/:requestId")
@@ -159,7 +160,7 @@ export class ExpertInitiatedBlameMirrorController {
); );
} }
@Post("/initial-form/:requestId") @Post("/run-inquiries/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: AddPlateDto }) @ApiBody({ type: AddPlateDto })
@ApiOperation({ @ApiOperation({
@@ -181,6 +182,29 @@ export class ExpertInitiatedBlameMirrorController {
); );
} }
@Post("/run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: InitialFormVinDto })
@ApiOperation({
summary: "[Expert mirror] Initial form via VIN/chassis inquiry for current party",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"Fills the FIRST or SECOND party insurance/vehicle data depending on the current workflow step.",
})
async initialFormVin(
@Param("requestId") requestId: string,
@Body() body: InitialFormVinDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
expert,
partyRole,
);
}
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",
@@ -362,20 +386,6 @@ export class ExpertInitiatedBlameMirrorController {
@CurrentUser() expert: any, @CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File, @UploadedFile() sign: Express.Multer.File,
) { ) {
// Guard: accident fields (accidentWay, etc.) must be filled before signing.
// This prevents the expert from bypassing add-accident-fields and avoids the
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES
// after a signature was already recorded.
const requestData = await this.requestManagementService.getBlameRequestV2(
requestId,
expert,
);
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
);
}
await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
const partyRole = const partyRole =
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
@@ -389,7 +399,7 @@ export class ExpertInitiatedBlameMirrorController {
); );
} }
@Post("add-accident-fields/:requestId") @Post("accident-fields/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: ExpertAccidentFieldsDto }) @ApiBody({ type: ExpertAccidentFieldsDto })
@ApiOperation({ @ApiOperation({

View File

@@ -0,0 +1,412 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Post,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
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 { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import {
CarBodyFormDto,
DescriptionDto,
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V4 FileMaker flow — first half of the split blame workflow.
*
* The FileMaker is responsible for everything up to and including the final
* party signature. After both signatures are collected the file is considered
* "sealed" and a FileReviewer can pick it up.
*
* THIRD_PARTY sequence:
* create → send-party-otp (guilty) → verify-party-otp (guilty)
* → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim)
* → add-detail-location / add-detail-description / upload-voice (guilty)
* → sign (guilty)
* → send-party-otp (damaged) → verify-party-otp (damaged)
* → run-inquiries (damaged)
* → add-detail-location / add-detail-description / upload-voice (damaged)
* → sign (damaged) ← FileMaker job done here
*
* CAR_BODY sequence: same but single party — omit second-party steps.
*/
@ApiTags("v4 FileMaker — blame file creation")
@Controller("v4/file-maker/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_MAKER)
export class FileMakerBlameV4Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
// ─── File creation ───────────────────────────────────────────────────────────
@Post()
@ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker)" })
@ApiBody({ type: CreateExpertInitiatedFileDto })
async create(
@CurrentUser() fileMaker: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
fileMaker,
// requiresFileMakerApproval is V5-only; explicitly exclude it from V4 so
// a client that accidentally sends the field cannot poison the blame record.
{ ...dto, creationMethod: CreationMethod.IN_PERSON, requiresFileMakerApproval: false },
);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during guilty-party run-inquiries. " +
"Share this claim ID with the FileReviewer.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileMaker,
requestId,
);
}
// ─── OTP ─────────────────────────────────────────────────────────────────────
@Post("send-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpDto })
@ApiOperation({
summary: "Send OTP to one party",
description:
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
})
async sendPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpDto,
) {
return this.requestManagementService.sendPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
@Post("verify-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpDto })
@ApiOperation({
summary: "Verify one party OTP",
description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.",
})
async verifyPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpDto,
) {
return this.requestManagementService.verifyPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
// ─── Pre-inquiry form ────────────────────────────────────────────────────────
@Post("car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@ApiOperation({
summary: "CAR_BODY only — accident type before inquiries",
description: "Submit after guilty-party OTP verify, before run-inquiries.",
})
async carBodyForm(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.carBodyAccidentTypeFormV3(
requestId,
body,
fileMaker,
);
}
@Post("run-inquiries/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesV3Dto })
@ApiOperation({
summary: "Run external inquiries for the current party",
description:
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiries(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesV3Dto,
) {
return this.requestManagementService.runInquiriesV3(
requestId,
fileMaker,
dto,
);
}
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V4)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(
requestId,
fileMaker,
dto,
);
}
// ─── Party details ────────────────────────────────────────────────────────────
@Post("add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@ApiOperation({
summary: "Add location for current party (partyRole FIRST or SECOND)",
})
async addLocation(
@Param("requestId") requestId: string,
@Body() body: LocationDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyLocationV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@Post("add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@ApiOperation({ summary: "Add description for current party" })
async addDescription(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyDescriptionV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
},
required: ["file"],
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v4-filemaker-voice-${unique}${ex}`);
},
}),
}),
)
@Post("upload-voice/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({ summary: "Upload voice for current party" })
async uploadVoice(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
@UploadedFile() voice: Express.Multer.File,
@Body("partyRole") partyRole?: string,
) {
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
return this.requestManagementService.addPartyVoiceV3(
requestId,
fileMaker,
voice,
partyRole,
);
}
// ─── Signatures (final FileMaker step) ───────────────────────────────────────
@Put("sign/:requestId")
@ApiParam({ name: "requestId" })
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Party on-site signature (FileMaker final step)",
description:
"Collect FIRST (guilty) then SECOND (damaged) signatures. " +
"After the last signature the file is ready for FileReviewer pickup.",
})
@ApiBody({
schema: {
type: "object",
required: ["partyRole", "sign"],
properties: {
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
isAccept: { type: "boolean", default: true },
sign: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v4-filemaker-party-${unique}${ex}`);
},
}),
}),
)
async sign(
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@CurrentUser() fileMaker: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
const partyRole =
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV3(
fileMaker,
requestId,
partyRole,
isAccept,
sign,
);
}
// ─── Claim document upload ────────────────────────────────────────────────────
@Post("upload-document/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@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);
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Upload one required claim document (FileMaker)",
description:
"Upload licenses and car cards against the auto-created claim. " +
"Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileMaker: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileMaker.sub,
fileMaker,
);
}
@Get("capture-requirements/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Capture requirements (step-aware)",
description:
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
})
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileMaker.sub,
fileMaker,
);
}
}

View File

@@ -0,0 +1,411 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Post,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
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 { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import {
CarBodyFormDto,
DescriptionDto,
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
*
* The only behavioural difference in the V5 flow is in the FileReviewer's final
* step: instead of moving directly to WAITING_FOR_EXPERT, the blame video moves
* the file to WAITING_FOR_FINANCIAL_EXPERT so a FinancialExpert can approve or
* reject before fanavaran submission.
*
* The FileMaker side is unchanged; all sequence and endpoints are the same.
*
* THIRD_PARTY sequence:
* create → send-party-otp (guilty) → verify-party-otp (guilty)
* → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim)
* → add-detail-location / add-detail-description / upload-voice (guilty)
* → sign (guilty)
* → send-party-otp (damaged) → verify-party-otp (damaged)
* → run-inquiries (damaged)
* → add-detail-location / add-detail-description / upload-voice (damaged)
* → sign (damaged) ← FileMaker job done here
*/
@ApiTags("v5 FileMaker — blame file creation (with FinancialExpert approval)")
@Controller("v5/file-maker/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_MAKER)
export class FileMakerBlameV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
// ─── File creation ───────────────────────────────────────────────────────────
@Post()
@ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker V5)" })
@ApiBody({ type: CreateExpertInitiatedFileDto })
async create(
@CurrentUser() fileMaker: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
fileMaker,
{ ...dto, creationMethod: CreationMethod.IN_PERSON, requiresFileMakerApproval: true },
);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during guilty-party run-inquiries. " +
"Share this claim ID with the FileReviewer.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileMaker,
requestId,
);
}
// ─── OTP ─────────────────────────────────────────────────────────────────────
@Post("send-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpDto })
@ApiOperation({
summary: "Send OTP to one party",
description:
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
})
async sendPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpDto,
) {
return this.requestManagementService.sendPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
@Post("verify-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpDto })
@ApiOperation({
summary: "Verify one party OTP",
description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.",
})
async verifyPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpDto,
) {
return this.requestManagementService.verifyPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
// ─── Pre-inquiry form ────────────────────────────────────────────────────────
@Post("car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@ApiOperation({
summary: "CAR_BODY only — accident type before inquiries",
description: "Submit after guilty-party OTP verify, before run-inquiries.",
})
async carBodyForm(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.carBodyAccidentTypeFormV3(
requestId,
body,
fileMaker,
);
}
@Post("run-inquiries/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesV3Dto })
@ApiOperation({
summary: "Run external inquiries for the current party",
description:
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiries(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesV3Dto,
) {
return this.requestManagementService.runInquiriesV3(
requestId,
fileMaker,
dto,
);
}
@Post("run-inquiries-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesVinV3Dto })
@ApiOperation({
summary: "Run VIN/chassis inquiries for the current party (V5)",
description:
"VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " +
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiriesVin(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesVinV3Dto,
) {
return this.requestManagementService.runInquiriesVinV3(
requestId,
fileMaker,
dto,
);
}
// ─── Party details ────────────────────────────────────────────────────────────
@Post("add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@ApiOperation({
summary: "Add location for current party (partyRole FIRST or SECOND)",
})
async addLocation(
@Param("requestId") requestId: string,
@Body() body: LocationDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyLocationV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@Post("add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@ApiOperation({ summary: "Add description for current party" })
async addDescription(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyDescriptionV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
},
required: ["file"],
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-filemaker-voice-${unique}${ex}`);
},
}),
}),
)
@Post("upload-voice/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({ summary: "Upload voice for current party" })
async uploadVoice(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
@UploadedFile() voice: Express.Multer.File,
@Body("partyRole") partyRole?: string,
) {
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
return this.requestManagementService.addPartyVoiceV3(
requestId,
fileMaker,
voice,
partyRole,
);
}
// ─── Signatures (final FileMaker step) ───────────────────────────────────────
@Put("sign/:requestId")
@ApiParam({ name: "requestId" })
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Party on-site signature (FileMaker final step)",
description:
"Collect FIRST (guilty) then SECOND (damaged) signatures. " +
"After the last signature the file is ready for FileReviewer pickup.",
})
@ApiBody({
schema: {
type: "object",
required: ["partyRole", "sign"],
properties: {
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
isAccept: { type: "boolean", default: true },
sign: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-filemaker-party-${unique}${ex}`);
},
}),
}),
)
async sign(
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@CurrentUser() fileMaker: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
const partyRole =
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV3(
fileMaker,
requestId,
partyRole,
isAccept,
sign,
);
}
// ─── Claim document upload ────────────────────────────────────────────────────
@Post("upload-document/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@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);
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Upload one required claim document (FileMaker)",
description:
"Upload licenses and car cards against the auto-created claim. " +
"Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileMaker: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileMaker.sub,
fileMaker,
);
}
@Get("capture-requirements/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Capture requirements (step-aware)",
description:
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
})
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileMaker.sub,
fileMaker,
);
}
}

View File

@@ -0,0 +1,109 @@
import {
Body,
Controller,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { ApiPropertyOptional } 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 { RequestManagementService } from "./request-management.service";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
class FileMakerRejectDto {
@ApiPropertyOptional({
description: "Reason for rejection (visible to FileReviewer)",
example: "Damage assessment is incorrect — please re-evaluate parts X and Y",
})
@IsOptional()
@IsString()
reason?: string;
}
/**
* V5 FileMaker approval panel.
*
* After the full claim flow completes (FileReviewer does damage assessment via
* expert-claim APIs, user signs), the claim lands in WAITING_FOR_FILE_MAKER_APPROVAL.
* The FileMaker who created the blame file can then:
*
* approve → triggers fanavaran submission (claim → COMPLETED)
* reject → sends claim back to WAITING_FOR_DAMAGE_EXPERT so the FileReviewer
* can re-lock, adjust pricing, and redo the user interaction
*
* All endpoints operate on `claimRequestId` (the claim case ID, not the blame ID).
* Use `GET v5/file-maker/blame-request-management/claim-id/:requestId` to obtain
* the claimRequestId from the original blame request ID.
*/
@ApiTags("v5 FileMaker — claim approval before fanavaran")
@Controller("v5/file-maker/claim-approval")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_MAKER)
export class FileMakerClaimApprovalV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Post("approve/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Approve the completed claim (FileMaker V5)",
description:
"Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
"Marks the claim COMPLETED and triggers fanavaran submission.",
})
async approve(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any,
) {
const result = await this.requestManagementService.fileMakerApproveV5(
fileMaker,
claimRequestId,
);
// Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate
// (requiresFileMakerApproval) has been cleared.
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
return { ...result, fanavaran };
}
@Post("reject/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FileMakerRejectDto })
@ApiOperation({
summary: "Reject the completed claim back to FileReviewer (FileMaker V5)",
description:
"Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
"Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " +
"adjust the damage assessment, and restart user interaction as needed.",
})
async reject(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FileMakerRejectDto,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.fileMakerRejectV5(
fileMaker,
claimRequestId,
body?.reason,
);
}
}

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