1
0
forked from Yara724/api

Compare commits

..

156 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
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
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
107 changed files with 11573 additions and 642 deletions

View File

@@ -5,6 +5,10 @@ NODE_ENV =
PORT =
CLIENT_ID =
CLIENT_NAME =
FANAVARAN_CLIENT=parsian
INSURANCE_CORP_ID='شرکت بيمه پارسيان(بيمه گر)'
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=false
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN=53000000
# ---------------------------------------------
# 🌐 Application URLs
# ---------------------------------------------
@@ -36,6 +40,7 @@ MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/$
# ---------------------------------------------
JWT_SECRET =
JWT_EXPIRY =
CAPTCHA_ENABLED = true
# ---------------------------------------------
# 🧩 SanHub Microservice
@@ -62,6 +67,23 @@ AUTH_SMS_TEMPLATE =
EXP_OTP_TIME =
FAKE_OTP =
# ---------------------------------------------
# 🌐 Proxy Configuration (Local Development Only)
# ---------------------------------------------
# NOTE: These proxy settings are for local development only.
# When deploying to the server, comment out or remove these lines
# as the server IP is already whitelisted by Fanavaran.
# SOCKS_PROXY_HOST = localhost
# SOCKS_PROXY_PORT = 6565
# ---------------------------------------------
# 🏢 Fanavaran Insurance Corp
# ---------------------------------------------
# Caption from Fanavaran insurance-corp lookup used to resolve InsuranceCorpId
# for damage-case payloads. Must match a Caption in the insurance-corp code-list.
# Example: "شرکت بيمه تجارت نو"
INSURANCE_CORP_ID =
# ---------------------------------------------
# ⚙️ Application Features / Flags
# ---------------------------------------------

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.

View File

@@ -1,7 +1,9 @@
# External API Curl Guide
This file documents outbound HTTP calls made by the app or maintenance scripts.
Use placeholder values for secrets, tokens, IDs, plate values, and payloads before running any curl.
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
@@ -32,29 +34,73 @@ Used by:
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 | from `src/core/config/fanavaran-client.config.ts` | hardcoded in source | hardcoded in source | hardcoded in source | `3539` | `263` | `100` |
| parsian | from `src/core/config/fanavaran-client.config.ts` | hardcoded in source | hardcoded in source | hardcoded in source | `543` | `28` | `210050` |
| 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 them as shell variables before running:
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
APP_NAME="<appname>"
APP_SECRET="<secret>"
FANAVARAN_USERNAME="<userName>"
FANAVARAN_PASSWORD="<password>"
CORP_ID="<CorpId>"
CONTRACT_ID="<ContractId>"
LOCATION="<Location>"
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" \
@@ -65,11 +111,21 @@ curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
The token is returned in a response header named `appToken` or `apptoken`.
```sh
APP_TOKEN="<response appToken header>"
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" \
@@ -81,7 +137,95 @@ curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
The token is returned in a response header or body field named `authenticationToken`, `authenticationtoken`, or `authentication_token`.
```sh
AUTHENTICATION_TOKEN="<response authenticationToken>"
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
@@ -130,6 +274,20 @@ curl -X GET "$FANAVARAN_BIME_URL/car/code-list/accident-culprit-type" \
-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
@@ -167,6 +325,8 @@ 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.
@@ -311,8 +471,10 @@ Used by `src/sand-hub/sand-hub.service.ts` for legacy inquiry flows.
Environment:
```sh
SANHUB_URL_LOGIN="<login URL>"
SANHUB_BASE_URL="<base URL>"
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
@@ -415,7 +577,7 @@ curl -X POST "$SANHUB_BASE_URL/sheba/sheba-tejaratno" \
Used by `src/claim-request-management/claim-request-management.service.ts`.
```sh
MAP_IR_API_KEY="<x-api-key>"
MAP_IR_API_KEY='eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg'
LAT="35.6892"
LON="51.3890"
@@ -431,7 +593,7 @@ curl -X GET "https://map.ir/fast-reverse?lat=$LAT&lon=$LON" \
Used by `src/sms-orchestration/provider/kavenegar.service.ts`.
```sh
SMS_API_KEY="<kavenegar API key>"
SMS_API_KEY='75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E'
KAVENEGAR_BASE_URL="https://api.kavenegar.com/v1/$SMS_API_KEY"
```
@@ -462,9 +624,9 @@ 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="<provider URL prefix>"
PARSIAN_API_KEY="<package API key>"
PARSIAN_BASIC_TOKEN="<basic token>"
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"
@@ -494,7 +656,9 @@ curl -X GET "${CW_URL}price?hamrah"
3. `POST $AI_URL_V2/services/car-damage/detector?version=ai-v7`
```sh
AI_URL_V2="<AI service base URL>"
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" \

1
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"class-validator": "^0.15.1",
"express": "^5.2.1",
"fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.6",
"joi": "^18.2.1",
"mongoose": "^8.9.2",
"pdfkit": "^0.19.1",

View File

@@ -39,11 +39,13 @@
"class-validator": "^0.15.1",
"express": "^5.2.1",
"fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.6",
"joi": "^18.2.1",
"mongoose": "^8.9.2",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socks-proxy-agent": "^8.0.4",
"svg-captcha": "^1.4.0"
},
"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",
/**
* 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",
CANCELLED = "CANCELLED",

View File

@@ -35,6 +35,26 @@ export enum ClaimCaseStatus {
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
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
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",

View File

@@ -6,4 +6,8 @@ export enum RoleEnum {
COMPANY = "company",
ADMIN = "admin",
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 { AiService } from "./ai.service";
@Module({
imports: [HttpModule],
imports: [],
providers: [AiService],
exports: [AiService],
})

View File

@@ -1,7 +1,8 @@
import { join } from "node:path";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
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 { MongooseModule } from "@nestjs/mongoose";
import { ServeStaticModule } from "@nestjs/serve-static";
@@ -27,9 +28,16 @@ import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { DatabaseModule } from "./core/database/database.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({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
AppConfigModule,
DatabaseModule,
CronModule,
@@ -59,6 +67,7 @@ import { AppConfigModule } from "./core/config/config.module";
ExpertInsurerModule,
LookupsModule,
WorkflowStepManagementModule,
SuperAdminModule,
],
controllers: [],
providers: [

View File

@@ -37,6 +37,7 @@ import {
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
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 { CurrentUser } from "src/decorators/user.decorator";
@@ -95,6 +96,7 @@ export class ActorAuthController {
* will be removed in a future release.
*/
@Post("register/genuine")
@UseGuards(SuperAdminGuard)
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Genuine actor registration",
@@ -111,6 +113,7 @@ export class ActorAuthController {
* will be removed in a future release.
*/
@Post("register/legal")
@UseGuards(SuperAdminGuard)
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Legal actor registration",
@@ -123,21 +126,24 @@ export class ActorAuthController {
}
@Post("register/insurer")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: InsurerRegisterDto })
async registerInsurer(@Body() body: InsurerRegisterDto) {
return await this.actorAuthService.insurerRegister(body);
}
/** Mock: create a field expert for testing. Make private later. */
/** Requires super-admin token. */
@Post("create-field-expert")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateFieldExpertDto })
@ApiAcceptedResponse()
async createFieldExpert(@Body() body: CreateFieldExpertDto) {
return await this.actorAuthService.createFieldExpertMock(body);
}
/** Mock: create a registrar for testing. Make private later. */
/** Requires super-admin token. */
@Post("create-registrar")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse()
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 { FieldExpertDbService } from "src/users/entities/db-service/field-expert.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 { 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[]) {
const out: Record<string, any> = {};
@@ -52,6 +56,10 @@ export class ActorAuthService {
private readonly clientDbService: ClientDbService,
private readonly otpService: OtpGeneratorService,
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
@@ -94,6 +102,37 @@ export class ActorAuthService {
case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username });
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:
return null;
}

View File

@@ -29,6 +29,11 @@ import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.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). */
@Global()
@@ -47,6 +52,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
schema: ClaimRequestManagementSchema,
},
{ name: ClaimCase.name, schema: ClaimCaseSchema },
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
]),
JwtModule.register({
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
@@ -61,6 +67,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
SuperAdminDbService,
],
exports: [
UserAuthService,
@@ -68,6 +75,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
SuperAdminDbService,
],
controllers: [UserAuthController, ActorAuthController],
})

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { HashService } from "src/utils/hash/hash.service";
@Injectable()
export class CaptchaChallengeService {
private readonly isDev: boolean;
private readonly captchaEnabled: boolean;
constructor(
private readonly captchaService: CaptchaService,
@@ -21,6 +22,7 @@ export class CaptchaChallengeService {
private readonly configService: ConfigService,
) {
this.isDev = this.configService.get<string>("NODE_ENV") === "development";
this.captchaEnabled = this.configService.get<string>("CAPTCHA_ENABLED") !== "false";
}
async issue(): Promise<CaptchaResponseDto> {
@@ -32,12 +34,17 @@ export class CaptchaChallengeService {
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({
captchaId,
answerHash,
image: generated.image,
expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt),
expireAt: new Date(generated.expiresAt + 120_000),
usedAt: null,
});
@@ -62,6 +69,11 @@ export class CaptchaChallengeService {
captchaId: string | undefined,
answer: string | undefined,
): Promise<void> {
// Skip captcha verification if disabled via environment variable
if (!this.captchaEnabled) {
return;
}
if (!captchaId?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}

View File

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

View File

@@ -1,5 +1,8 @@
import { Module } from "@nestjs/common";
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 { SandHubModule } from "src/sand-hub/sand-hub.module";
import { RequestManagementModule } from "src/request-management/request-management.module";
@@ -52,13 +55,18 @@ import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt";
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({
imports: [
HttpModule,
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
FanavaranAuditModule,
FanavaranLookupModule,
PublicIdModule,
UsersModule,
RequestManagementModule,

View File

@@ -13,10 +13,19 @@ import {
Get,
UseInterceptors,
UploadedFile,
UploadedFiles,
} from "@nestjs/common";
import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiParam,
ApiTags,
ApiOperation,
ApiResponse,
ApiBody,
ApiConsumes,
} from "@nestjs/swagger";
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname } from "node:path";
import { Types } from "mongoose";
@@ -33,9 +42,15 @@ import {
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import {
SelectOtherPartsV2Dto,
SelectOtherPartsV2ResponseDto,
} from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "./dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
@@ -52,7 +67,13 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@Controller("v2/claim-request-management")
@ApiBearerAuth()
@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 {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -142,7 +163,10 @@ export class ClaimRequestManagementV2Controller {
})
@ApiParam({ name: "claimRequestId" })
@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(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any,
@@ -156,15 +180,19 @@ export class ClaimRequestManagementV2Controller {
} catch (error) {
if (error instanceof HttpException) throw error;
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).
* Accepts multipart/form-data so optional supporting invoices can be attached in the same request.
*/
@Put("request/:claimRequestId/objection")
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Submit user objection (V2)",
description:
@@ -172,30 +200,75 @@ export class ClaimRequestManagementV2Controller {
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
"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({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
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: 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: 404, description: "Claim not found" })
@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(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto,
@CurrentUser() user: any,
@UploadedFiles() invoices?: Express.Multer.File[],
) {
for (const file of invoices ?? []) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
}
try {
return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId,
body,
user.sub,
user,
invoices,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -222,7 +295,10 @@ export class ClaimRequestManagementV2Controller {
})
@ApiBody({ type: UserRatingDto })
@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: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Rating already submitted" })
@@ -270,21 +346,32 @@ export class ClaimRequestManagementV2Controller {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: { type: "string", format: "binary", description: "Signature image" },
sign: {
type: "string",
format: "binary",
description: "Signature image",
},
agree: {
type: "boolean",
description: "true to accept expert pricing and complete the claim",
},
branchId: {
type: "string",
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
description:
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011",
},
},
},
})
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
@ApiResponse({
status: 200,
description: "Signature stored; claim completed or rejected",
})
@ApiResponse({
status: 400,
description: "Wrong step/status or missing file",
})
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Already signed" })
@@ -314,7 +401,9 @@ export class ClaimRequestManagementV2Controller {
}
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
const agreed =
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
typeof agree === "string"
? agree === "true" || agree === "1"
: Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
@@ -453,8 +542,7 @@ export class ClaimRequestManagementV2Controller {
})
@ApiBody({
type: SelectOuterPartsV2Dto,
description:
"Selected vehicle type + selected outer part IDs from catalog",
description: "Selected vehicle type + selected outer part IDs from catalog",
examples: {
example1: {
summary: "Sedan - minor front damage",
@@ -522,9 +610,7 @@ export class ClaimRequestManagementV2Controller {
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to select outer parts",
error instanceof Error ? error.message : "Failed to select outer parts",
);
}
}
@@ -638,9 +724,7 @@ Optional: upload car green card file in the same step.
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to select other parts",
error instanceof Error ? error.message : "Failed to select other parts",
);
}
}
@@ -862,7 +946,7 @@ Returns status of each item (uploaded/captured or not).
type: "string",
example: "front",
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: {
type: "string",
@@ -1005,7 +1089,10 @@ Returns status of each item (uploaded/captured or not).
description: "Video uploaded successfully",
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: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Video already uploaded" })
@@ -1025,9 +1112,10 @@ Returns status of each item (uploaded/captured or not).
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload car capture video",
error instanceof Error
? error.message
: "Failed to upload car capture video",
);
}
}
}

View File

@@ -1,34 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/**
* V2 DTO for capturing car angle or damaged part
*/
export class CapturePartV2Dto {
@ApiProperty({
description: 'Type of capture: angle or part',
example: 'angle',
enum: ['angle', 'part'],
description: "Type of capture: angle or part",
example: "angle",
enum: ["angle", "part"],
})
@IsNotEmpty({ message: 'Capture type is required' })
@IsEnum(['angle', 'part'], {
@IsNotEmpty({ message: "Capture type is required" })
@IsEnum(["angle", "part"], {
message: 'Capture type must be either "angle" or "part"',
})
captureType: 'angle' | 'part';
captureType: "angle" | "part";
@ApiProperty({
description:
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
example: 'front',
example: "front",
})
@IsNotEmpty({ message: 'Capture key is required' })
@IsString({ message: 'Capture key must be a string' })
@IsNotEmpty({ message: "Capture key is required" })
@IsString({ message: "Capture key must be a string" })
captureKey: string;
@ApiProperty({
type: 'string',
format: 'binary',
description: 'Image file (JPG, PNG)',
type: "string",
format: "binary",
description: "Image file (JPG, PNG)",
})
file: Express.Multer.File;
}
@@ -38,51 +38,58 @@ export class CapturePartV2Dto {
*/
export class CapturePartV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
description: "Claim request ID",
example: "507f1f77bcf86cd799439011",
})
claimRequestId: string;
@ApiProperty({
description: 'Type of capture',
example: 'angle',
description: "Type of capture",
example: "angle",
})
captureType: string;
@ApiProperty({
description: 'Key of what was captured',
example: 'front',
description: "Key of what was captured",
example: "front",
})
captureKey: string;
@ApiProperty({
description: 'File URL',
example: 'http://localhost:3000/files/captures/front-1234567890.jpg',
description: "File URL",
example: "http://localhost:3000/files/captures/front-1234567890.jpg",
})
fileUrl: string;
@ApiProperty({
description: 'Whether all captures are now complete',
description: "Whether all captures are now complete",
example: false,
})
allCapturesComplete: boolean;
@ApiProperty({
description: 'Current workflow step',
example: 'CAPTURE_PART_DAMAGES',
description: "Current workflow step",
example: "CAPTURE_PART_DAMAGES",
})
currentStep: string;
@ApiProperty({
description: 'Success message',
example: 'Angle captured successfully. 6 captures remaining.',
description: "Success message",
example: "Angle captured successfully. 6 captures remaining.",
})
message: string;
@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;
@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 {
@ApiProperty({
description: 'Claim case ID',
example: '507f1f77bcf86cd799439011',
description: "Claim case ID",
example: "507f1f77bcf86cd799439011",
})
claimRequestId: string;
@ApiProperty({
description: 'ID of the stored video document (claim-video-capture)',
example: '507f1f77bcf86cd799439012',
description: "ID of the stored video document (claim-video-capture)",
example: "507f1f77bcf86cd799439012",
})
videoId: string;
@ApiProperty({
description: 'Success message',
example: 'Video capture uploaded successfully.',
description: "Success message",
example: "Video capture uploaded successfully.",
})
message: string;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class CreateClaimFromBlameResponseDto {
@ApiProperty({
@@ -23,4 +23,10 @@ export class CreateClaimFromBlameResponseDto {
example: "Claim request created successfully",
})
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({
description: 'Sheba number (masked for security)',
example: 'IR12************1234',
required: false,
})
shebaNumber: string;
shebaNumber?: string;
@ApiProperty({
description: 'National code of owner (masked)',
example: '12******90',
required: false,
})
nationalCodeOfOwner: string;
nationalCodeOfOwner?: string;
@ApiProperty({
description: 'Current workflow step',

View File

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

View File

@@ -1,26 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
/**
* V2 DTO for uploading required document
*/
export class UploadRequiredDocumentV2Dto {
@ApiProperty({
description: 'Document type/key',
example: 'car_green_card',
description: "Document type/key",
example: "car_green_card",
enum: ClaimRequiredDocumentType,
})
@IsNotEmpty({ message: 'Document key is required' })
@IsNotEmpty({ message: "Document key is required" })
@IsEnum(ClaimRequiredDocumentType, {
message: 'Invalid document type',
message: "Invalid document type",
})
documentKey: ClaimRequiredDocumentType;
@ApiProperty({
type: 'string',
format: 'binary',
description: 'Image file (JPG, PNG, PDF)',
type: "string",
format: "binary",
description: "Image file (JPG, PNG, PDF)",
})
file: Express.Multer.File;
}
@@ -30,43 +30,51 @@ export class UploadRequiredDocumentV2Dto {
*/
export class UploadRequiredDocumentV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
description: "Claim request ID",
example: "507f1f77bcf86cd799439011",
})
claimRequestId: string;
@ApiProperty({
description: 'Document key that was uploaded',
example: 'car_green_card',
description: "Document key that was uploaded",
example: "car_green_card",
})
documentKey: string;
@ApiProperty({
description: 'File URL',
example: 'http://localhost:3000/files/documents/car-green-card-1234567890.jpg',
description: "File URL",
example:
"http://localhost:3000/files/documents/car-green-card-1234567890.jpg",
})
fileUrl: string;
@ApiProperty({
description: 'Whether all required documents are now uploaded',
description: "Whether all required documents are now uploaded",
example: false,
})
allDocumentsUploaded: boolean;
@ApiProperty({
description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
description: "Current workflow step",
example: "UPLOAD_REQUIRED_DOCUMENTS",
})
currentStep: string;
@ApiProperty({
description: 'Success message',
example: 'Document uploaded successfully. 12 documents remaining.',
description: "Success message",
example: "Document uploaded successfully. 12 documents remaining.",
})
message: string;
@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;
@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 { Type } from "class-transformer";
import { Type, Transform } from "class-transformer";
import {
IsArray,
IsOptional,
@@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/**
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
* with nested validation enabled for the v2 controller pipeline.
* V2 user objection body — submitted as multipart/form-data.
* `objectionParts` and `newParts` are JSON-encoded strings in the form fields.
* Optional `invoices` files are uploaded as a `invoices` file array.
*/
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)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
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)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
newParts?: NewPartDto[];
}

View File

@@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart {
export const ClaimUserObjectionNewPartSchema =
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).
* Stored on {@link ClaimEvaluation.objection}.
@@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload {
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
/** Optional supporting invoices uploaded at objection time. */
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
invoices?: ClaimObjectionInvoice[];
}
export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload);

View File

@@ -58,6 +58,64 @@ export class RequiredDocumentRef {
export const RequiredDocumentRefSchema =
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({
collection: "claimCases",
timestamps: true,
@@ -142,6 +200,15 @@ export class ClaimCase {
@Prop({ type: Number })
claimId?: number;
@Prop({ type: Number })
dmgCaseId?: number;
@Prop({ type: Number })
expertiseId?: number;
@Prop({ type: FanavaranSyncStateSchema, default: () => ({}) })
fanavaranSync?: FanavaranSyncState;
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
damage?: ClaimDamageSelection;
@@ -184,6 +251,20 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true })
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.
* If you choose to migrate later, we can remove these.

View File

@@ -4,9 +4,12 @@ import {
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Patch,
Post,
Put,
Query,
UploadedFile,
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")
@ApiConsumes("multipart/form-data")
@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 {
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
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 { SystemSettingsService } from "src/system-settings/system-settings.service";
import { ClientService } from "./client.service";
@@ -21,26 +30,35 @@ export class ClientController {
) {}
@Post()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Create a new insurer client (super-admin only)" })
async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client);
}
@Get()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClient(@CurrentUser() user) {
return await this.clientService.getClients();
}
@Get("list")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList();
}
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({
summary: "Enable or disable live external inquiries",
summary: "Enable or disable live external inquiries (super-admin only)",
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({
type: SetExternalInquiriesLiveDto,
@@ -52,7 +70,8 @@ export class ClientController {
},
disableLive: {
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 },
},
},

View File

@@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
})
@IsBoolean()
carOwnership: boolean;
@ApiProperty({
description:
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
example: false,
})
@IsBoolean()
vinChassis: boolean;
}
export class UpdateClientExternalInquiriesDto extends PartialType(

View File

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

View File

@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
import {
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
@@ -102,4 +103,20 @@ export class ListQueryV2Dto {
@IsOptional()
@IsIn([...LIST_FILE_TYPE_V2])
fileType?: ListFileTypeV2;
@ApiPropertyOptional({
description: "Filter start date (ISO 8601). Only files created on or after this date are returned.",
example: "2025-01-01T00:00:00.000Z",
})
@IsOptional()
@IsISO8601({ strict: false })
startDate?: string;
@ApiPropertyOptional({
description: "Filter end date (ISO 8601). Only files created on or before this date are returned.",
example: "2025-12-31T23:59:59.999Z",
})
@IsOptional()
@IsISO8601({ strict: false })
endDate?: string;
}

View File

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

View File

@@ -5,7 +5,9 @@ export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
"tejaratno",
] as const;
export function isFanavaranClientKey(value: string): value is FanavaranClientKey {
export function isFanavaranClientKey(
value: string,
): value is FanavaranClientKey {
const normalized = value?.trim().toLowerCase();
return normalized === "parsian" || normalized === "tejaratno";
}
@@ -35,9 +37,17 @@ export interface FanavaranPayloadDefaults {
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
ExpertiseClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
DmgCaseTypeId: number;
DmgHistoryStatus: number;
PlaqueKindId: number;
PlaqueSampleId: number;
DriverIsOwner: number;
FaultPercent: number;
ClaimFileTypeId: number;
}
export interface FanavaranClientProfile {
@@ -65,10 +75,18 @@ const FANAVARAN_CLIENT_PROFILES: Record<
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 1589,
ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 4543092,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23,
},
},
parsian: {
@@ -87,9 +105,17 @@ const FANAVARAN_CLIENT_PROFILES: Record<
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 154,
ExpertiseClaimExpertId: 29,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 70,
},
},
};
@@ -127,14 +153,14 @@ export function fanavaranPreviewPath(
clientKey: FanavaranClientKey,
claimCaseId: string,
): string {
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}`;
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}`;
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}/base-claim/submit`;
}
export function fanavaranManualSubmitPath(claimCaseId: string): string {

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;
partiesInitialForms: { firstParty: string; secondParty: 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 {

View File

@@ -12,6 +12,7 @@ import {
assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert,
blameCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
@@ -438,6 +439,9 @@ export class ExpertBlameService {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertBlameListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerBlameListV2(actor, query);
}
requireActorClientKey(actor);
const expertId = actor.sub;
@@ -579,6 +583,44 @@ export class ExpertBlameService {
return pagedResult;
}
/**
* Blame inbox for FILE_REVIEWER — all expert-initiated IN_PERSON files that
* have been sealed by a FileMaker (WAITING_FOR_FILE_REVIEWER) and belong to
* this reviewer's insurance company (clientKey).
*
* Once the FileReviewer completes their steps the file moves to
* WAITING_FOR_EXPERT (via upload-video), after which it is visible in the
* standard expert-blame panel as usual.
*/
private async getFileReviewerBlameListV2(
actor: { sub: string; clientKey?: string },
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
const clientKey = requireActorClientKey(actor);
const allSealed = (await this.blameRequestDbService.find(
{
expertInitiated: true,
status: {
$in: [
CaseStatus.WAITING_FOR_FILE_REVIEWER,
CaseStatus.WAITING_FOR_EXPERT,
CaseStatus.COMPLETED,
],
},
},
{ lean: true },
)) as Record<string, unknown>[];
// Scope to this reviewer's insurance company via blame party clientId
const visibleCases = allSealed.filter((doc) =>
blameCaseTouchesClient(doc, clientKey),
);
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
return pagedResult;
}
private async paginateBlameListV2(
visibleCases: Record<string, unknown>[],
query: ListQueryV2Dto,
@@ -588,11 +630,21 @@ export class ExpertBlameService {
String((d as { publicId?: string }).publicId ?? ""),
),
);
const filtered = this.filterBlameDocsByUnifiedStatus(
let filtered = this.filterBlameDocsByUnifiedStatus(
visibleCases,
claimByPublicId,
query.unifiedStatus,
);
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
filtered = filtered.filter((doc) =>
isInListDateRange(
(doc as { createdAt?: Date }).createdAt,
fromDate,
toDate,
),
);
}
const paged = applyListQueryV2(
filtered,
@@ -727,6 +779,10 @@ export class ExpertBlameService {
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),
};
}
@@ -1815,6 +1871,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
role,
"v1",
),
});
}
@@ -2009,6 +2066,7 @@ export class ExpertBlameService {
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
linkRole,
"v1",
),
});
}

View File

@@ -131,16 +131,26 @@ export class ClaimDetailV2ResponseDto {
})
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({
description:
"Slice of `claim.evaluation` exposed to the damage expert. " +
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
"awaiting factor validation. `ownerInsurerApproval` and " +
"`ownerPricedPartsApproval` are returned whenever they exist on the " +
"claim — each carries `signLink` (resolved from `signDetailId`) so the " +
"front-end can render the user signature directly. Likewise, " +
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
"expose `signLink` when a user comment signature is present.",
"`damageExpertReply` / `damageExpertReplyFinal` are returned whenever " +
"present, regardless of the current claim status — historical assessment " +
"data remains visible once the expert has submitted it. " +
"`ownerInsurerApproval` and `ownerPricedPartsApproval` are returned " +
"whenever they exist on the claim — each carries `signLink` (resolved " +
"from `signDetailId`) so the front-end can render the user signature " +
"directly. Likewise, `damageExpertReply.userComment` / " +
"`damageExpertReplyFinal.userComment` expose `signLink` when a user " +
"comment signature is present. `priceDrop` is included whenever it " +
"has been saved, not only during the expert review phase.",
})
evaluation?: {
damageExpertReply?: unknown;

View File

@@ -56,6 +56,12 @@ export class ClaimListItemV2Dto {
})
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' })
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`.",
})
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 {

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 { 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 { AiModule } from "src/ai/ai.module";
import { AuthModule } from "src/auth/auth.module";
@@ -21,7 +23,11 @@ import { ExpertClaimService } from "./expert-claim.service";
@Module({
imports: [
HttpModule,
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
SandHubModule,
MongooseModule.forFeature([
{ name: ClaimFactorsImage.name, schema: ClaimFactorsImageSchema },

View File

@@ -12,6 +12,10 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import {
BusinessErrorCode,
BusinessRuleException,
} from "./exceptions/business-rule.exception";
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
import { Types } from "mongoose";
import { lastValueFrom } from "rxjs";
@@ -19,6 +23,7 @@ import { ClaimRequestManagementDbService } from "src/claim-request-management/en
import {
ClaimRequestManagementService,
FanavaranAutoSubmitResult,
FanavaranExpertiseSubmitResult,
} from "src/claim-request-management/claim-request-management.service";
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
@@ -58,6 +63,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import {
assertClaimCaseForTenant,
assertClaimCaseForExpertActor,
blameCaseTouchesClient,
claimCaseInitiatedByFieldExpert,
claimCaseTouchesClient,
requireActorClientKey,
@@ -289,7 +295,7 @@ export class ExpertClaimService {
private appendFanavaranAutoSubmitToMessage(
baseMessage: string,
fanavaran: FanavaranAutoSubmitResult,
fanavaran: FanavaranAutoSubmitResult | FanavaranExpertiseSubmitResult,
): string {
if (fanavaran.submitted) {
return `${baseMessage} The claim was sent to Fanavaran successfully.`;
@@ -366,6 +372,21 @@ export class ExpertClaimService {
await enrichApproval("ownerInsurerApproval");
await enrichApproval("ownerPricedPartsApproval");
// Resolve objection invoice paths to downloadable URLs.
const objection = ev.objection as Record<string, unknown> | undefined;
if (objection) {
const invoices = objection.invoices as Array<Record<string, unknown>> | undefined;
if (Array.isArray(invoices) && invoices.length > 0) {
ev.objection = {
...objection,
invoices: invoices.map((inv) => ({
...inv,
url: inv.path ? buildFileLink(inv.path as string) : undefined,
})),
};
}
}
return ev;
}
@@ -382,6 +403,12 @@ export class ExpertClaimService {
claim: any,
actor: any,
): Promise<void> {
// FILE_REVIEWER: by the time we reach this method the caller has already
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
// the reviewer. Skip it — the assignment check is the access proof.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
const blame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, blame);
}
@@ -539,6 +566,23 @@ export class ExpertClaimService {
return this.expertVehicleFromPartyVehicle(party?.vehicle);
}
/** Extract car name / model from the inquiry snapshot embedded on the claim. */
private vehicleNamesFromClaimInquiries(claim: any): {
carName?: string;
carModel?: string;
} {
// Claim copies inquiries from blame at creation time.
// Try thirdParty first, then carBody.
const mapped =
claim?.inquiries?.thirdParty?.data?.FIRST?.mapped ??
claim?.inquiries?.carBody?.data?.FIRST?.mapped ??
{};
const carName: string | undefined =
mapped.MapTypNam || mapped.SystemField || undefined;
const carModel: string | undefined = mapped.TypeField || undefined;
return { carName, carModel };
}
private vehicleForExpertFromClaimAndBlameMap(
claim: any,
blameById: Map<string, any>,
@@ -552,10 +596,24 @@ export class ExpertClaimService {
| undefined {
const cv = claim?.vehicle;
if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) {
const carType: string | undefined = cv.carType;
// carName/carModel that match the enum value (e.g. "sedan") are invalid —
// the expert may have accidentally copied the carType value into those fields.
// Fall back to the inquiry snapshot for the real brand / variant strings.
let carName: string | undefined = cv.carName;
let carModel: string | undefined = cv.carModel;
if (
(!carName || carName === carType) ||
(!carModel || carModel === carType)
) {
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
if (!carName || carName === carType) carName = fromInquiry.carName;
if (!carModel || carModel === carType) carModel = fromInquiry.carModel;
}
return {
carName: cv.carName,
carModel: cv.carModel,
carType: cv.carType,
carName,
carModel,
carType,
...((cv as any).plate ? { plate: (cv as any).plate } : {}),
};
}
@@ -2371,16 +2429,24 @@ export class ExpertClaimService {
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
const fanavaranExpertise =
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
claimRequestId,
);
return {
message: this.appendFanavaranAutoSubmitToMessage(
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
fanavaran,
this.appendFanavaranAutoSubmitToMessage(
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
fanavaran,
),
fanavaranExpertise,
),
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
fanavaran,
fanavaranExpertise,
};
}
@@ -2397,17 +2463,25 @@ export class ExpertClaimService {
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
const fanavaranExpertise =
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
claimRequestId,
);
return {
message: this.appendFanavaranAutoSubmitToMessage(
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
fanavaran,
this.appendFanavaranAutoSubmitToMessage(
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
fanavaran,
),
fanavaranExpertise,
),
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_AUTO_COMPLETED",
fanavaran,
fanavaranExpertise,
};
}
@@ -2459,6 +2533,104 @@ export class ExpertClaimService {
throw new HttpException(error.response, error.claimStatus);
}
/**
* FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame
* file. Operates on the blame document (not the claim workflow lock).
*
* Rules:
* - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated.
* - Status must be WAITING_FOR_FILE_REVIEWER.
* - No other reviewer may have taken it (assignedFileReviewerId absent/null).
* - Idempotent: same reviewer calling again gets "already_assigned_to_you".
*/
private async assignFileReviewerToV4Blame(
claimRequestId: string,
claim: any,
actor: { sub: string; fullName?: string; clientKey?: string },
): Promise<ExpertFileAssignResultDto> {
if (!claim.blameRequestId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This claim has no linked blame file.",
});
}
const blame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
if (!blame) {
throw new NotFoundException("Linked blame file not found.");
}
if (!(blame as any).isMadeByFileMaker) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
});
}
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`,
});
}
const existing = (blame as any).assignedFileReviewerId;
if (existing) {
if (String(existing) === actor.sub) {
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
}
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Another FileReviewer has already taken this file.",
});
}
// Atomically claim it — findOneAndUpdate with null/missing guard
const reviewerOid = new Types.ObjectId(actor.sub);
const updated = await this.blameRequestDbService.findOneAndUpdate(
{
_id: (blame as any)._id,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ $set: { assignedFileReviewerId: reviewerOid } },
{ new: true },
);
if (!updated) {
// Another reviewer won the race
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Another FileReviewer has already taken this file.",
});
}
const now = new Date();
this.logger.log(
`FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`,
);
return {
success: true,
status: "assigned",
assignedAt: now.toISOString(),
message: "File assigned to you. Proceed with accident fields and field capture.",
};
}
/**
* Assign the current damage expert to a claim for review (V2).
* Free cases are locked atomically; already-assigned-to-self is idempotent.
@@ -2472,7 +2644,10 @@ export class ExpertClaimService {
role?: string;
},
): Promise<ExpertFileAssignResultDto> {
if ((actor as any).role !== RoleEnum.FIELD_EXPERT)
if (
(actor as any).role !== RoleEnum.FIELD_EXPERT &&
(actor as any).role !== RoleEnum.FILE_REVIEWER
)
requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
@@ -2481,6 +2656,57 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found");
}
// FILE_REVIEWER: two-phase lock behaviour.
// Phase 1 — blame is WAITING_FOR_FILE_REVIEWER: atomically set
// assignedFileReviewerId on the blame document.
// Phase 2 — blame is already past WAITING_FOR_FILE_REVIEWER (i.e. the
// reviewer finished field work and blame moved to WAITING_FOR_EXPERT):
// the reviewer now needs the standard damage-expert workflow lock
// so they can perform damage assessment. Fall through to the
// damage-expert path below; assertExpertActorOnClaim will verify
// tenant scope via the reviewer's clientKey.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
if (!claim.blameRequestId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This claim has no linked blame file.",
});
}
const reviewerBlame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
if (!reviewerBlame) {
throw new NotFoundException("Linked blame file not found.");
}
const blameStatus = (reviewerBlame as any).status as string;
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
// Phase 1: blame-assign path
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
}
// Phase 2: blame is past the initial assignment step.
// Only the reviewer who was assigned during Phase 1 may proceed.
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId)
: null;
if (!assignedReviewerId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "No reviewer has been assigned to this file yet.",
});
}
if (assignedReviewerId !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "This file is assigned to another reviewer.",
});
}
// Assigned reviewer — fall through to the damage-expert workflow lock below.
// (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope)
}
await this.assertExpertActorOnClaim(claim, actor);
const isFieldExpertOwner =
@@ -2856,7 +3082,8 @@ export class ExpertClaimService {
}
if (existingResend?.fulfilledAt) {
throw new BadRequestException(
throw new BusinessRuleException(
BusinessErrorCode.DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED,
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
);
}
@@ -2937,7 +3164,7 @@ export class ExpertClaimService {
receptor: ownerPhoneResend,
fileKind: "claim",
publicId: claim.publicId,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
});
}
@@ -3235,10 +3462,12 @@ export class ExpertClaimService {
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
});
}
// Fanavaran expertise is now triggered on owner final sign, not here.
return {
claimRequestId,
status: nextCaseStatus,
@@ -3305,6 +3534,7 @@ export class ExpertClaimService {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
status: ClaimCaseStatus.COMPLETED,
"workflow.locked": false,
$unset: {
"workflow.lockedAt": "",
@@ -3342,7 +3572,7 @@ export class ExpertClaimService {
return {
claimRequestId,
status: claim.status,
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: "In-person visit requested. User will be notified.",
};
@@ -3486,14 +3716,19 @@ export class ExpertClaimService {
let claims: any[] = [];
if (actor.role === RoleEnum.FIELD_EXPERT) {
const expertOid = new Types.ObjectId(actor.sub);
// Exclude V4/V5 blame files — same rule as getFieldExpertClaimListV2.
const expertBlameIds = await this.blameRequestDbService
.find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
{
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
isMadeByFileMaker: { $ne: true },
},
{ select: "_id", lean: true },
)
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
{ initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
@@ -3573,6 +3808,12 @@ export class ExpertClaimService {
(item) => item.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
filtered = filtered.filter((item) =>
isInListDateRange(item.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2(
filtered,
{
@@ -3618,6 +3859,12 @@ export class ExpertClaimService {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertClaimListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerClaimListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_MAKER) {
return this.getFileMakerClaimListV2(actor, query);
}
requireActorClientKey(actor);
const actorId = actor.sub;
const clientKey = actor.clientKey as string;
@@ -3746,7 +3993,7 @@ export class ExpertClaimService {
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus" },
{ lean: true, select: "type parties expert.decision blameStatus status isMadeByFileMaker" },
)) as any[])
: [];
const blameById = new Map<string, any>(
@@ -3792,6 +4039,7 @@ export class ExpertClaimService {
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
: undefined,
...fileCtx,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation,
};
@@ -3809,15 +4057,22 @@ export class ExpertClaimService {
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const expertOid = new Types.ObjectId(actor.sub);
// Exclude V4/V5 blame files (isMadeByFileMaker=true) — those belong to
// the FILE_MAKER/FILE_REVIEWER flows, not the V3 field-expert flow.
const expertBlameIds = await this.blameRequestDbService
.find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
{
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
isMadeByFileMaker: { $ne: true },
},
{ select: "_id", lean: true },
)
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
// Direct claim link: exclude V5 claims that require FileMaker approval
{ initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
@@ -3838,7 +4093,7 @@ export class ExpertClaimService {
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus" },
{ lean: true, select: "type parties expert.decision blameStatus status" },
)) as any[])
: [];
const blameById = new Map<string, any>(
@@ -3877,8 +4132,207 @@ export class ExpertClaimService {
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
: undefined,
...fileCtx,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* Claim list for FILE_REVIEWER.
*
* Visibility rules:
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
* (a) not yet assigned to any reviewer, OR
* (b) already assigned to THIS reviewer
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer
* (they completed the field work and the file is now in damage-expert queue)
*
* All scoped to this reviewer's insurance company via blame party clientId.
*/
private async getFileReviewerClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const clientKey = requireActorClientKey(actor);
const reviewerOid = new Types.ObjectId(actor.sub);
// Find V4 blame files visible to this reviewer
const blames = (await this.blameRequestDbService.find(
{
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
$or: [
// Open: sealed by FileMaker, not yet taken by any reviewer
{
status: "WAITING_FOR_FILE_REVIEWER",
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
// Taken by this reviewer (any post-assignment status)
{ assignedFileReviewerId: reviewerOid },
],
},
{
lean: true,
select:
"_id type parties blameStatus status expert.decision assignedFileReviewerId",
},
)) as any[];
if (blames.length === 0) {
return this.paginateClaimListV2([], query);
}
// Scope to this reviewer's insurer via blame party clientId
const scopedBlames = blames.filter((b) =>
blameCaseTouchesClient(b, clientKey),
);
if (scopedBlames.length === 0) {
return this.paginateClaimListV2([], query);
}
const scopedBlameIds = scopedBlames.map((b) => b._id);
const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: scopedBlameIds },
})) as any[];
const blameById = new Map<string, any>(
scopedBlames.map((b) => [String(b._id), b]),
);
const list = claims.map((c) => {
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
const assignedToMe =
blame?.assignedFileReviewerId &&
String(blame.assignedFileReviewerId) === actor.sub;
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
currentStep: c.workflow?.currentStep || "",
locked: lockActive,
lockedBy:
lockActive && c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
}
: undefined,
vehicle: v
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
: undefined,
...fileCtx,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
assignedToMe: !!assignedToMe,
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* Claim list for FILE_MAKER — returns only the claims linked to blame files
* they personally created (V4 flow).
*/
private async getFileMakerClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const makerOid = new Types.ObjectId(actor.sub);
const makerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" },
)) as any[];
if (makerBlames.length === 0) {
return this.paginateClaimListV2([], query);
}
const blameIds = makerBlames.map((b) => b._id);
const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: blameIds },
requiresFileMakerApproval: true,
})) as any[];
const blameById = new Map<string, any>(
makerBlames.map((b) => [String(b._id), b]),
);
const list = claims.map((c) => {
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
currentStep: c.workflow?.currentStep || "",
locked: lockActive,
lockedBy:
lockActive && c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
}
: undefined,
vehicle: v
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
: undefined,
...fileCtx,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval,
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
assignedFileReviewerId: blame?.assignedFileReviewerId
? String(blame.assignedFileReviewerId)
: undefined,
};
}) as ClaimListItemV2Dto[];
@@ -4062,7 +4516,11 @@ export class ExpertClaimService {
clientKey?: string;
role?: string;
}): Promise<void> {
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
if (
(actor as any).role === RoleEnum.FIELD_EXPERT ||
(actor as any).role === RoleEnum.FILE_REVIEWER ||
(actor as any).role === RoleEnum.FILE_MAKER
) return;
const clientKey = requireActorClientKey(actor);
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
const now = new Date();
@@ -4222,7 +4680,11 @@ export class ExpertClaimService {
actor: any,
): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub;
if (actor.role !== RoleEnum.FIELD_EXPERT) {
if (
actor.role !== RoleEnum.FIELD_EXPERT &&
actor.role !== RoleEnum.FILE_MAKER &&
actor.role !== RoleEnum.FILE_REVIEWER
) {
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
}
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
@@ -4233,7 +4695,12 @@ export class ExpertClaimService {
}
const linkedBlame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
// FILE_REVIEWER access is validated by the role-specific gate below
// (isAssignedToMe || isOpen). Skip the generic tenant-scope assert which
// would incorrectly reject them via the initiatedByFieldExpertId path.
if (actor.role !== RoleEnum.FILE_REVIEWER) {
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
}
// Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase =
@@ -4242,13 +4709,63 @@ export class ExpertClaimService {
const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim);
// Field experts can always view their own initiated claims regardless of status
if (actor.role === RoleEnum.FIELD_EXPERT) {
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
throw new ForbiddenException("This claim was not initiated by you.");
// FileMaker: can view any file they created (both during filing and approval phase).
// The list endpoint already filters to V5-only; detail access is scoped by ownership.
if (actor.role === RoleEnum.FILE_MAKER) {
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
if (!isOwn) {
throw new ForbiddenException(
"FileMakers can only view files they created.",
);
}
// Fall through to the detail build below
} else if (
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
// regardless of claim status (they work across multiple non-standard steps).
actor.role === RoleEnum.FIELD_EXPERT ||
actor.role === RoleEnum.FILE_REVIEWER
) {
if (actor.role === RoleEnum.FILE_REVIEWER) {
// FILE_REVIEWER: must be a V4/V5 file AND (they are the assigned reviewer OR
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
const isV4V5Blame =
(linkedBlame as any)?.isMadeByFileMaker &&
linkedBlame?.expertInitiated &&
linkedBlame?.creationMethod === "IN_PERSON";
if (!isV4V5Blame) {
throw new ForbiddenException(
"FileReviewers can only access V4/V5 FileMaker files.",
);
}
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
? String((linkedBlame as any).assignedFileReviewerId)
: null;
const isAssignedToMe =
assignedReviewerId && assignedReviewerId === actorId;
const isOpen =
!assignedReviewerId &&
String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER";
if (!isAssignedToMe && !isOpen) {
throw new ForbiddenException(
"This file has been taken by another reviewer.",
);
}
} else {
// FIELD_EXPERT: V3 flow only. Reject V4/V5 files (isMadeByFileMaker)
// even if their ID matches initiatedByFieldExpertId on the blame —
// those are FILE_MAKER files, accessible via the FILE_MAKER role only.
const isV4V5Blame = !!(linkedBlame as any)?.isMadeByFileMaker;
if (isV4V5Blame) {
throw new ForbiddenException(
"Field experts can only access V3 expert-initiated files.",
);
}
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
throw new ForbiddenException("This claim is not accessible to you.");
}
}
// Fall through to the detail build below (skip the damage-expert gate)
} else {
} else if (actor.role !== RoleEnum.FILE_MAKER) {
const isResendPending =
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
@@ -4413,6 +4930,24 @@ export class ExpertClaimService {
if (fromBlame) vehiclePayload = fromBlame;
}
// Patch up carName/carModel when they equal carType (invalid — expert copied the enum value).
if (vehiclePayload) {
const carType: string | undefined = vehiclePayload.carType;
if (
carType &&
((!vehiclePayload.carName || vehiclePayload.carName === carType) ||
(!vehiclePayload.carModel || vehiclePayload.carModel === carType))
) {
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
if (!vehiclePayload.carName || vehiclePayload.carName === carType) {
vehiclePayload = { ...vehiclePayload, carName: fromInquiry.carName };
}
if (!vehiclePayload.carModel || vehiclePayload.carModel === carType) {
vehiclePayload = { ...vehiclePayload, carModel: fromInquiry.carModel };
}
}
}
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
@@ -4465,15 +5000,18 @@ export class ExpertClaimService {
let evaluationForApi: Record<string, unknown> | undefined;
if (enrichedEvaluation) {
evaluationForApi = {};
if (isFactorValidationPending) {
if (enrichedEvaluation.damageExpertReply !== undefined) {
evaluationForApi.damageExpertReply =
enrichedEvaluation.damageExpertReply;
}
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
evaluationForApi.damageExpertReplyFinal =
enrichedEvaluation.damageExpertReplyFinal;
}
// damageExpertReply / damageExpertReplyFinal: include whenever present.
// Previously these were gated on isFactorValidationPending, which hid the
// expert assessment once the claim moved to user-side statuses such as
// INSURER_REVIEW_AWAITING_OWNER_SIGN. Historical assessment data should
// always be visible once submitted.
if (enrichedEvaluation.damageExpertReply !== undefined) {
evaluationForApi.damageExpertReply =
enrichedEvaluation.damageExpertReply;
}
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
evaluationForApi.damageExpertReplyFinal =
enrichedEvaluation.damageExpertReplyFinal;
}
if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
evaluationForApi.ownerInsurerApproval =
@@ -4483,9 +5021,14 @@ export class ExpertClaimService {
evaluationForApi.ownerPricedPartsApproval =
enrichedEvaluation.ownerPricedPartsApproval;
}
if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) {
// priceDrop: include whenever present, not only during the expert phase.
if (enrichedEvaluation.priceDrop !== undefined) {
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
}
// objection: include when present so experts can see the disputed parts and any attached invoices.
if (enrichedEvaluation.objection !== undefined) {
evaluationForApi.objection = enrichedEvaluation.objection;
}
if (Object.keys(evaluationForApi).length === 0) {
evaluationForApi = undefined;
}
@@ -4528,6 +5071,7 @@ export class ExpertClaimService {
carAngles,
damagedParts,
awaitingFactorValidation: isFactorValidationPending,
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
evaluation:
(evaluationForApi as
| ClaimDetailV2ResponseDto["evaluation"]

View File

@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import {
Body,
Controller,
@@ -62,7 +63,7 @@ class InPersonVisitV2Dto {
@Controller("v2/expert-claim")
@ApiBearerAuth()
@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 {
constructor(
private readonly expertClaimService: ExpertClaimService,
@@ -117,6 +118,21 @@ export class ExpertClaimV2Controller {
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")
@ApiOperation({
summary: "List insurer branches for this damage expert (V2)",
@@ -153,7 +169,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Get claim request detail for damage expert",
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" })
async getClaimDetailV2(
@@ -281,7 +297,20 @@ export class ExpertClaimV2Controller {
description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
"**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" })
@ApiBody({ type: ClaimSubmitResendV2Dto })

View File

@@ -81,3 +81,19 @@ export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
})
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 {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "./dto/create-insurer-expert.dto";
@Controller("expert-insurer")
@@ -97,20 +99,32 @@ export class ExpertInsurerController {
@Put("branches/:branchId/status")
@ApiParam({ name: "branchId" })
@ApiQuery({ name: "isActive", type: Boolean })
@ApiBody({
schema: {
type: "object",
properties: { isActive: { type: "boolean" } },
required: ["isActive"],
},
})
async setBranchStatus(
@CurrentUser() insurer,
@Param("branchId") branchId: string,
@Query("isActive") isActive: string,
@Body("isActive") isActive: unknown,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
const normalized = String(isActive).trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be true/false");
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
let active: boolean;
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(
insurer.clientKey,
branchId,
@@ -142,6 +156,32 @@ export class ExpertInsurerController {
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: "response_count", type: Number })
@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")
@ApiParam({ name: "publicId" })
async getFileDetailsByPublicId(

View File

@@ -12,6 +12,8 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
CreateInsurerExpertDto,
} from "./dto/create-insurer-expert.dto";
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 { ExpertDbService } from "src/users/entities/db-service/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 { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import { HashService } from "src/utils/hash/hash.service";
@@ -71,6 +75,7 @@ import {
extractExpertNamesFromBlame,
extractExpertNamesFromClaim,
} from "./helper/insurer.helper";
import { getEventFaLabel } from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
@Injectable()
@@ -89,6 +94,8 @@ export class ExpertInsurerService {
private readonly videoCaptureDbService: VideoCaptureDbService,
private readonly clientDbService: ClientDbService,
private readonly claimSignDbService: ClaimSignDbService,
private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService,
) {}
/**
@@ -1328,6 +1335,12 @@ export class ExpertInsurerService {
(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(
files,
@@ -1513,6 +1526,7 @@ export class ExpertInsurerService {
ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
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
* Returns:
@@ -1973,4 +2089,77 @@ export class ExpertInsurerService {
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

@@ -41,6 +41,72 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
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 = {

View File

@@ -1,9 +1,17 @@
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],
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
],
providers: [FanavaranLookupService],
exports: [FanavaranLookupService],
})

View File

@@ -14,6 +14,7 @@ import {
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import {
FANAVARAN_LOOKUP_BASE_URL,
fanavaranLookupCacheDir,
tejaratStaticAccidentFilePath,
} from "./fanavaran-lookup.config";
@@ -182,24 +183,6 @@ export class FanavaranLookupService {
this.logger.log(
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
);
this.logger.log(
`[${clientKey}] Fanavaran lookup raw response: ${JSON.stringify(
response.data,
null,
2,
)}`,
);
if (Array.isArray(response.data) && response.data.length > 0) {
const firstItem = response.data[0];
if (firstItem && typeof firstItem === "object") {
this.logger.log(
`[${clientKey}] Fanavaran lookup first item keys: ${Object.keys(
firstItem,
).join(", ")}`,
);
}
}
return response.data;
} catch (error) {
@@ -246,6 +229,162 @@ export class FanavaranLookupService {
}
}
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 }[] {
@@ -270,4 +409,5 @@ export class FanavaranLookupService {
};
});
}
}

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
@@ -54,11 +55,11 @@ export class FanavaranController {
};
}
@Get(":client/claim-cases/:claimCaseId")
@Get(":client/claim-cases/:claimCaseId/base-claim/preview")
@ApiOperation({
summary: "Preview Fanavaran submit payload (V2)",
summary: "Preview Fanavaran base claim create payload",
description:
"Builds the third-party-car-financial-claims body from claimCases + blameCases without calling Fanavaran.",
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.",
})
@ApiParam({
name: "client",
@@ -87,11 +88,11 @@ export class FanavaranController {
);
}
@Post(":client/claim-cases/:claimCaseId")
@Post(":client/claim-cases/:claimCaseId/base-claim/submit")
@ApiOperation({
summary: "Submit claim to Fanavaran (V2)",
summary: "Submit Fanavaran base claim create request",
description:
"Authenticates with the selected client credentials and submits the mapped claimCases + blameCases payload.",
"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",
@@ -105,11 +106,173 @@ export class FanavaranController {
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,
);
}

View File

@@ -8,6 +8,9 @@ export enum FanavaranAuditStep {
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 {
@@ -31,13 +34,23 @@ export class FanavaranAuditLog {
@Prop({ type: String, required: true, enum: FanavaranAuditStep, index: true })
step: FanavaranAuditStep;
@Prop({ type: String, required: true, enum: FanavaranAuditStatus, index: true })
@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 })
@Prop({
type: String,
required: true,
enum: FanavaranAuditSource,
index: true,
})
source: FanavaranAuditSource;
@Prop({ type: Types.ObjectId, required: false, index: true })

View File

@@ -16,6 +16,12 @@ export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
"damaged_metal_plate",
] 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 =
| "parts"
| "angles"
@@ -52,7 +58,7 @@ function isRequiredDocumentUploadedOnClaim(
export function getClaimCaptureProgress(
claimCase: any,
options?: { assumeCapturePhaseDocKey?: string },
options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean },
): ClaimCaptureProgress {
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
@@ -83,6 +89,7 @@ export function getClaimCaptureProgress(
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
if (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
},
@@ -111,8 +118,11 @@ export function getClaimCaptureProgress(
};
}
export function isClaimCaptureStepComplete(claimCase: any): boolean {
const p = getClaimCaptureProgress(claimCase);
export function isClaimCaptureStepComplete(
claimCase: any,
options?: { skipMetalPlate?: boolean },
): boolean {
const p = getClaimCaptureProgress(claimCase, options);
return (
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
);

View File

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

View File

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

View File

@@ -22,20 +22,18 @@ const LABEL_COLUMN_X = COLON_X + 10;
function resolveFontFile(variant: "regular" | "bold"): string {
const file =
variant === "bold"
? "NotoKufiArabic-Bold.ttf"
: "NotoKufiArabic-Regular.ttf";
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", "fonts", file),
join(process.cwd(), "dist", "assets", file),
join(process.cwd(), "dist", "assets", "fonts", file),
join("/usr/share/fonts/truetype/noto", file),
];
for (const path of candidates) {
if (existsSync(path)) return path;
}
throw new Error(
`Persian PDF font not found (${file}). Place it under assets/fonts/ or install noto fonts on the host.`,
`Persian PDF font not found (${file}). Place it under assets/ or assets/fonts/.`,
);
}
@@ -46,6 +44,16 @@ export type PersianPdfDocument = InstanceType<typeof PDFDocument> & {
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,
@@ -69,6 +77,9 @@ function drawKeyValueRow(
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);
@@ -118,6 +129,9 @@ export function createPersianPdfDocument(): PersianPdfDocument {
};
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);

View File

@@ -8,18 +8,31 @@ export function normalizePersianPdfText(text: string): string {
.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: normalizePersianPdfText(label),
value: normalizePersianPdfText(value),
label: reverseRtlWords(normalizePersianPdfText(label)),
value: reverseRtlWords(normalizePersianPdfText(value)),
};
}
export function pdfKitRtlParagraph(text: string): string {
return normalizePersianPdfText(text);
return reverseRtlWords(normalizePersianPdfText(text));
}
export function isMostlyAscii(text: string): boolean {

View File

@@ -31,6 +31,8 @@ const CLAIM_USER_PHASE = new Set<string>([
ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
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>([

View File

@@ -1,8 +1,18 @@
import { Controller, Get } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from "@nestjs/common";
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { AuthGuard } from "src/common/auth/guards";
import { LookupsService } from "./lookups.service";
@ApiTags("lookups")
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Controller("lookups")
export class LookupsController {
constructor(private readonly lookupsService: LookupsService) {}
@@ -96,9 +106,247 @@ export class LookupsController {
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")
@ApiOkResponse({
description: "Returns accident way options for the add-accident-fields step",
description:
"Returns accident way options for the add-accident-fields step",
schema: {
type: "array",
items: {
@@ -204,5 +452,75 @@ export class LookupsController {
async 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

@@ -2,6 +2,7 @@ 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";
@@ -31,10 +32,14 @@ export class LookupsService {
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 Error(`Unknown Fanavaran remote lookup: ${name}`);
throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`);
}
return lookup;
}
@@ -52,7 +57,7 @@ export class LookupsService {
return doc.response;
}
private async getClientRemoteLookup(lookupName: string): Promise<unknown> {
async getClientRemoteLookup(lookupName: string): Promise<unknown> {
const clientKey = this.activeClientKey();
const definition = this.findRemoteLookup(lookupName);
@@ -90,6 +95,137 @@ export class LookupsService {
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 }[]> {
const clientKey = this.activeClientKey();
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay;
@@ -129,10 +265,9 @@ export class LookupsService {
if (clientKey === "parsian") {
const cacheFile = "accident-reason-options.json";
const cached =
await this.fanavaranLookupService.readCacheFile<
{ id: number; label: string; fanavaran: number }[]
>(clientKey, cacheFile);
const cached = await this.fanavaranLookupService.readCacheFile<
{ id: number; label: string; fanavaran: number }[]
>(clientKey, cacheFile);
if (cached) {
return cached;
}

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 { IsNotEmpty, IsString, MaxLength } from "class-validator";
import { Types } from "mongoose";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
@@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 {
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 {
// @ApiProperty()
// firstPartyFile?: FirstPartyFileDto;

View File

@@ -1,5 +1,5 @@
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";
export class CreateExpertInitiatedFileDto {
@@ -18,4 +18,17 @@ export class CreateExpertInitiatedFileDto {
})
@IsEnum(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,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
@@ -89,3 +90,66 @@ export class RunInquiriesV3Dto {
@IsString()
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

@@ -109,6 +109,48 @@ export class BlameRequest {
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
@Prop({ type: String, enum: 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>;

View File

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

View File

@@ -31,7 +31,7 @@ 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 } from "./dto/run-inquiries-v3.dto";
import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto";
import {
CarBodyFormDto,
DescriptionDto,
@@ -181,6 +181,23 @@ export class ExpertInitiatedBlameV3MirrorController {
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")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })

View File

@@ -1,6 +1,5 @@
import { extname } from "node:path";
import {
BadRequestException,
Body,
Controller,
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 { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { InitialFormVinDto } from "./dto/create-request-management.dto";
import {
BlameConfessionDtoV2,
CarBodyFormDto,
@@ -35,6 +35,7 @@ import {
LocationDto,
} from "./dto/create-request-management.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 { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
@@ -76,17 +77,17 @@ export class ExpertInitiatedBlameMirrorController {
@ApiOperation({
summary: "[Expert mirror] Create expert-initiated blame file",
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 })
async create(
@CurrentUser() expert: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
expert,
dto,
);
return this.requestManagementService.createExpertInitiatedBlameV2(expert, {
...dto,
creationMethod: CreationMethod.IN_PERSON,
});
}
@Post("send-link/:requestId")
@@ -159,7 +160,7 @@ export class ExpertInitiatedBlameMirrorController {
);
}
@Post("/initial-form/:requestId")
@Post("/run-inquiries/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: AddPlateDto })
@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({
schema: {
type: "object",
@@ -362,20 +386,6 @@ export class ExpertInitiatedBlameMirrorController {
@CurrentUser() expert: any,
@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");
const partyRole =
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" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@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,
);
}
}

View File

@@ -0,0 +1,479 @@
import { extname } from "node:path";
import {
BadRequestException,
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Patch,
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 { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { RequestManagementService } from "./request-management.service";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { 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 {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V4 FileReviewer flow — second half of the split blame workflow.
*
* The FileReviewer picks up a sealed file (both signatures collected by the
* FileMaker) and completes the damage-assessment portion: accident fields,
* documents, part selection, photo capture, and walk-around video (final step).
*
* Sequence:
* accident-fields
* → capture-requirements (via linked claimRequestId)
* → upload-document (licenses, car cards)
* → select-outer-parts
* → select-other-parts
* → capture-part (damaged-part photos + four angles)
* → upload-document (chassis / engine — capture phase)
* → car-capture (walk-around video — FINAL FileReviewer step)
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
*
* NOTE: upload-video is a no-op for V4 (blame is already COMPLETED by car-capture).
*/
@ApiTags("v4 FileReviewer — blame file review & capture")
@Controller("v4/file-reviewer/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_REVIEWER)
export class FileReviewerBlameV4Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
// ─── Linked claim lookup ──────────────────────────────────────────────────────
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " +
"Use this claim ID for all capture/document/parts endpoints below.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileReviewer,
requestId,
);
}
// ─── Accident fields ──────────────────────────────────────────────────────────
@Post("accident-fields/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@ApiOperation({
summary: "Accident type / fields (FileReviewer first step)",
description:
"Saves accident fields on the sealed file. " +
"Does not move to WAITING_FOR_EXPERT — that happens after the final blame accident video.",
})
async addAccidentFields(
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV3(
fileReviewer,
requestId,
fields,
);
}
// ─── Capture requirements ─────────────────────────────────────────────────────
@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() fileReviewer: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileReviewer.sub,
fileReviewer,
);
}
// ─── 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",
description:
"Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " +
"Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Part selection ───────────────────────────────────────────────────────────
@Patch("select-outer-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SelectOuterPartsV2Dto })
@ApiOperation({ summary: "Select damaged outer parts" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiOperation({
summary: "Select other damaged parts (V4)",
description:
"Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.",
})
@ApiBody({
description:
"Other parts selection. Bank info is already on the claim from run-inquiries.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{
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" },
],
example: ["engine", "suspension"],
},
},
},
})
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV3Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOtherPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOtherPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Photo capture ────────────────────────────────────────────────────────────
@Post("capture-part/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v4-capture-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Car angles and damaged-part photos",
description:
"CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " +
"(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
},
file: { type: "string", format: "binary" },
},
},
})
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── 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 (V4 — FileReviewer acts on behalf of user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer'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() fileReviewer: 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,
fileReviewer.sub,
fileReviewer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
// ─── Walk-around video ────────────────────────────────────────────────────────
@Patch("car-capture/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v4-claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Walk-around video (final FileReviewer step — V4)",
description:
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED.",
})
async carCapture(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
return this.claimRequestManagementService.setVideoCaptureV3(
claimRequestId,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Final blame video ────────────────────────────────────────────────────────
@ApiBody({
schema: {
type: "object",
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v4-blame-accident-${unique}${ex}`);
},
}),
}),
)
@Post("upload-video/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({
summary: "Blame accident video (no-op for V4 — car-capture is the final step)",
description:
"Deprecated for V4. The blame is already COMPLETED by car-capture. " +
"Calling this endpoint returns an idempotent success response.",
})
async uploadBlameVideo(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
@UploadedFile() file: Express.Multer.File,
) {
if (file) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
}
return this.requestManagementService.expertUploadBlameVideoV3(
fileReviewer,
requestId,
file,
);
}
}

View File

@@ -0,0 +1,480 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Patch,
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 { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { RequestManagementService } from "./request-management.service";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { 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 {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V5 FileReviewer flow — same as V4 except after the damage expert completes
* their review, the claim goes to WAITING_FOR_FILE_MAKER_APPROVAL instead of
* being submitted directly to fanavaran.
*
* Sequence:
* accident-fields
* → capture-requirements (via linked claimRequestId)
* → upload-document (licenses, car cards)
* → select-outer-parts
* → select-other-parts
* → capture-part (damaged-part photos + four angles)
* → upload-document (chassis / engine — capture phase)
* → car-capture (walk-around video — FINAL FileReviewer step)
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
* ↳ after expert review: claim → WAITING_FOR_FILE_MAKER_APPROVAL
*
* NOTE: upload-video is a no-op for V5 (blame is already COMPLETED by car-capture).
*/
@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)")
@Controller("v5/file-reviewer/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_REVIEWER)
export class FileReviewerBlameV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
// ─── Linked claim lookup ──────────────────────────────────────────────────────
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " +
"Use this claim ID for all capture/document/parts endpoints below.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileReviewer,
requestId,
);
}
// ─── Accident fields ──────────────────────────────────────────────────────────
@Post("accident-fields/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@ApiOperation({
summary: "Accident type / fields (FileReviewer first step)",
description:
"Saves accident fields on the sealed file. " +
"Does not move to WAITING_FOR_FINANCIAL_EXPERT — that happens after the final blame accident video.",
})
async addAccidentFields(
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV3(
fileReviewer,
requestId,
fields,
);
}
// ─── Capture requirements ─────────────────────────────────────────────────────
@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() fileReviewer: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileReviewer.sub,
fileReviewer,
);
}
// ─── 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",
description:
"Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " +
"Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Part selection ───────────────────────────────────────────────────────────
@Patch("select-outer-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SelectOuterPartsV2Dto })
@ApiOperation({ summary: "Select damaged outer parts" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiOperation({
summary: "Select other damaged parts (V5)",
description:
"Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.",
})
@ApiBody({
description:
"Other parts selection. Bank info is already on the claim from run-inquiries.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{
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" },
],
example: ["engine", "suspension"],
},
},
},
})
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV3Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOtherPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOtherPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Photo capture ────────────────────────────────────────────────────────────
@Post("capture-part/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-capture-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Car angles and damaged-part photos",
description:
"CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " +
"(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
},
file: { type: "string", format: "binary" },
},
},
})
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── 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 (V5 — FileReviewer acts on behalf of user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer'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() fileReviewer: 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,
fileReviewer.sub,
fileReviewer,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
// ─── Walk-around video ────────────────────────────────────────────────────────
@Patch("car-capture/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Walk-around video (final FileReviewer step — V5)",
description:
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED. " +
"After the damage expert review completes the claim moves to WAITING_FOR_FILE_MAKER_APPROVAL.",
})
async carCapture(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
return this.claimRequestManagementService.setVideoCaptureV3(
claimRequestId,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Final blame video ─────────────────────────────────────────────────────────
//
@ApiBody({
schema: {
type: "object",
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-blame-accident-${unique}${ex}`);
},
}),
}),
)
@Post("upload-video/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({
summary: "Blame accident video (no-op for V5 — car-capture is the final step)",
description:
"Deprecated for V5. The blame is already COMPLETED by car-capture. " +
"Calling this endpoint returns an idempotent success response.",
})
async uploadBlameVideo(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
@UploadedFile() file: Express.Multer.File,
) {
if (file) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
}
return this.requestManagementService.expertUploadBlameVideoV5(
fileReviewer,
requestId,
file,
);
}
}

View File

@@ -367,7 +367,8 @@ export class InquiryRefreshService {
}
next.insurance.policyNumber =
mapped.LastCompanyDocumentNumber ||
mapped.PrntPlcyCmpDocNo ||
mapped.printNumber ||
mapped.insuranceNumber ||
next.insurance.policyNumber;
next.insurance.company =

View File

@@ -28,6 +28,7 @@ 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 { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { InitialFormVinDto } from "./dto/create-request-management.dto";
import {
CarBodyFormDto,
DescriptionDto,
@@ -165,6 +166,29 @@ export class RegistrarBlameMirrorController {
);
}
@Post("/initial-form-vin/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: InitialFormVinDto })
@ApiOperation({
summary: "[Registrar mirror] Initial form via VIN/chassis inquiry for current party",
description:
"VIN alternative to initial-form. 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() registrar: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
registrar,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",

View File

@@ -43,8 +43,14 @@ import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.m
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller";
import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller";
import { FileMakerBlameV4Controller } from "./file-maker-blame-v4.controller";
import { FileReviewerBlameV4Controller } from "./file-reviewer-blame-v4.controller";
import { FileMakerBlameV5Controller } from "./file-maker-blame-v5.controller";
import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controller";
import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller";
import { InquiryRefreshController } from "./inquiry-refresh.controller";
import { InquiryRefreshService } from "./inquiry-refresh.service";
import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller";
@Module({
imports: [
@@ -81,7 +87,13 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
ExpertInitiatedBlameV3MirrorController,
RegistrarInitiatedController,
RegistrarBlameMirrorController,
FileMakerBlameV4Controller,
FileReviewerBlameV4Controller,
FileMakerBlameV5Controller,
FileReviewerBlameV5Controller,
FileMakerClaimApprovalV5Controller,
InquiryRefreshController,
CallCenterBlameV6Controller,
],
providers: [
RequestManagementService,

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ import {
BlameConfessionDtoV2,
CreateBlameRequestDtoV2,
DescriptionDto,
InitialFormVinDto,
LocationDto,
CarBodyFormDto,
} from "./dto/create-request-management.dto";
@@ -197,6 +198,26 @@ export class RequestManagementV2Controller {
return this.requestManagementService.initialFormV2(requestId, body, user);
}
@Post("/initial-form-vin/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
type: InitialFormVinDto,
description:
"Same identity/license fields as the plate form, but with `vin` (17-char chassis number) instead of `plate`.",
})
@UseGuards(GlobalGuard)
async initialFormVinV2(
@Param("requestId") requestId: string,
@Body() body: InitialFormVinDto,
@CurrentUser() user,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
user,
);
}
@Post("/add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })

View File

@@ -1,5 +1,7 @@
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 { MongooseModule } from "@nestjs/mongoose";
import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
@@ -9,7 +11,11 @@ import { SandHubService } from "./sand-hub.service";
@Module({
imports: [
HttpModule,
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
SystemSettingsModule,
ClientModule,
MongooseModule.forFeature([

View File

@@ -81,5 +81,33 @@ describe("SandHubService inquiry mocks", () => {
expect(httpService.post).not.toHaveBeenCalled();
expect(result.mapped?.CompanyName).toBe("بیمه پارسیان");
expect(result.mapped?.CompanyCode).toBe("8");
// PrntPlcyCmpDocNo must be populated from the mock raw field
expect(result.mapped?.PrntPlcyCmpDocNo).toBe("1404/1143-70591/200/123");
});
it("maps printNumber → PrntPlcyCmpDocNo when raw response uses new field name", async () => {
// Simulate a real-API response where the policy number arrives as printNumber
const rawNewFormat = {
printNumber: "1405/1143-NEWAPI/200/1",
CompanyName: "بیمه آزمایشی",
CompanyCode: "99",
FinancialCvrCptl: "5000000000",
IssueDate: "1405/01/01",
EndDate: "1406/01/01",
};
const mapped = (service as any).mapNewApiResponseToOldFormat(rawNewFormat);
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-NEWAPI/200/1");
});
it("maps insuranceNumber → PrntPlcyCmpDocNo when raw response uses legacy field name", async () => {
const rawLegacy = {
insuranceNumber: "1405/1143-LEGACY/200/1",
CompanyName: "بیمه آزمایشی",
CompanyCode: "99",
};
const mapped = (service as any).mapNewApiResponseToOldFormat(rawLegacy);
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1");
});
});

View File

@@ -893,6 +893,48 @@ export class SandHubService {
};
}
/**
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
*
* When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
* can normalise it into the same `{ raw, mapped }` contract used by the plate path.
*
* @param chassisNo - 17-character VIN / chassis number
* @param options - optional per-tenant client scope
*/
async getPolicyByChassisInquiry(
chassisNo: string,
options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
const requestPayload = { chassisNo };
const live = await this.isInquiryLive("vinChassis", options);
if (!live) {
const ctx = await this.mockCompanyContext(options);
const raw = this.buildMockPlateInquiryRaw(ctx);
this.logger.debug(
`[MOCK] getPolicyByChassisInquiry chassisNo=${chassisNo}`,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
return { raw, mapped };
}
const raw = await this.makeEsgRequest(
requestUrl,
requestPayload,
"vinChassis",
options,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
return { raw, mapped };
}
private async makeSandHubRequest(
url: string,
payload: any,
@@ -970,11 +1012,20 @@ export class SandHubService {
IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
EndDate: newResponse.persianEndDate || newResponse.EndDate,
// Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo;
// newer API formats surface it as printNumber or insuranceNumber.
PrntPlcyCmpDocNo:
newResponse.PrntPlcyCmpDocNo ||
newResponse.printNumber ||
newResponse.insuranceNumber ||
null,
// Insurance details
LastCompanyDocumentNumber:
newResponse.lastCompanyInsuranceNumber ||
newResponse.LastCompanyDocumentNumber ||
newResponse.insuranceNumber,
newResponse.insuranceNumber ||
null,
// Technical details
MtrNum: newResponse.MtrNum || newResponse.mtrnum,

View File

@@ -1,6 +1,7 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { KavenegarService } from "./kavenegar.service";
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
@@ -8,7 +9,14 @@ import { ParsianSmsGateway } from "./parsian-sms.gateway";
import { SmsGatewayService } from "./sms-gateway.service";
@Module({
imports: [HttpModule, ConfigModule],
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
ConfigModule,
],
providers: [
KavenegarService,
KavenegarSmsGateway,

View File

@@ -6,10 +6,16 @@ import { SmsGatewayModule } from "./provider/sms-gateway.module";
import { OtpGeneratorService } from "./otp-generator.service";
import { SmsOrchestrationService } from "./sms-orchestration.service";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
@Module({
imports: [
HttpModule,
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
SmsGatewayModule,
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
],

View File

@@ -37,20 +37,25 @@ export class SmsOrchestrationService implements OnModuleInit {
);
}
buildInviteLink(frontendRoute: string, requestId: string): string {
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${frontendRoute}?token=${requestId}`;
buildInviteLink(
frontendRoute: string,
requestId: string,
versionPrefix: string,
): string {
return `${process.env.URL}/${versionPrefix}/${frontendRoute}?token=${requestId}`;
}
buildBlamePartyLink(
requestId: string,
partyRole: "FIRST" | "SECOND",
versionPrefix: string,
): string {
const route = partyRole === "SECOND" ? "user2" : "user";
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${route}?token=${requestId}`;
return `${process.env.URL}/${versionPrefix}/${route}?token=${requestId}`;
}
buildClaimLink(claimRequestId: string): string {
return `${process.env.URL}/${process.env.USER_BASE_PATH}/caseClaim?token=${claimRequestId}`;
buildClaimLink(claimRequestId: string, versionPrefix: string): string {
return `${process.env.URL}/${versionPrefix}/caseClaim?token=${claimRequestId}`;
}
async sendInviteLink(

View File

@@ -218,5 +218,9 @@
"دمنده بخاری": false,
"کمپرسور کولر": false,
"کندانسور": false,
"اواپراتور": false
"اواپراتور": false,
"لاستیک": false,
"مه شکن": false,
"رینگ": false,
"فن": false
}

View File

@@ -0,0 +1,110 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateSuperAdminDto {
@ApiProperty({
example: "ops@yara724.ir",
description: "Email address for the new super-admin account.",
})
@IsEmail()
email: string;
@ApiProperty({ example: "Str0ngP@ss!" })
@IsString()
@IsNotEmpty()
password: string;
@ApiPropertyOptional({ example: "Operations" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: "Team" })
@IsOptional()
@IsString()
lastName?: string;
}
export class CreateFieldExpertAdminDto {
@ApiProperty({ example: "expert@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "Expert@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ example: "Ali" })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: "Mohammadi" })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: "09121234567" })
@IsString()
@IsNotEmpty()
mobile: string;
@ApiPropertyOptional({ example: "02112345678" })
@IsOptional()
@IsString()
phone?: string;
}
export class CreateRegistrarAdminDto {
@ApiProperty({ example: "registrar@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "Registrar@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({
example: "664a1b2c3d4e5f6789012345",
description: "Mongo ObjectId of the insurer client this registrar belongs to.",
})
@IsString()
@IsNotEmpty()
clientId: string;
}
export class CreateCallCenterAgentDto {
@ApiProperty({ example: "agent@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "CallCenter@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ example: "Sara" })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: "Hosseini" })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({
example: "664a1b2c3d4e5f6789012345",
description: "Mongo ObjectId of the insurer client this agent belongs to.",
})
@IsString()
@IsNotEmpty()
clientId: string;
@ApiPropertyOptional({ example: "09121234567" })
@IsOptional()
@IsString()
mobile?: string;
}

View File

@@ -0,0 +1,74 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose";
import { HashService } from "src/utils/hash/hash.service";
import {
SuperAdminModel,
SuperAdminDocument,
} from "../schema/super-admin.schema";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Injectable()
export class SuperAdminDbService implements OnModuleInit {
private readonly logger = new Logger(SuperAdminDbService.name);
constructor(
@InjectModel(SuperAdminModel.name)
private readonly superAdminModel: Model<SuperAdminDocument>,
private readonly hashService: HashService,
) {}
/**
* Seeds the default super-admin account on first boot.
* Reads credentials from environment variables:
* SUPER_ADMIN_EMAIL (default: super-admin@yara724.ir)
* SUPER_ADMIN_PASSWORD (default: SuperAdmin@724)
*/
async onModuleInit() {
const email = (
process.env.SUPER_ADMIN_EMAIL ?? "super-admin@yara724.ir"
).toLowerCase().trim();
const existing = await this.superAdminModel.findOne({ email }).lean();
if (existing) {
this.logger.log(`Super-admin already exists (email=${email}), skipping seed.`);
return;
}
const rawPassword = process.env.SUPER_ADMIN_PASSWORD ?? "SuperAdmin@724";
const password = await this.hashService.hash(rawPassword);
await this.superAdminModel.create({
email,
password,
role: RoleEnum.SUPER_ADMIN,
firstName: "Super",
lastName: "Admin",
});
this.logger.log(`Default super-admin seeded (email=${email}).`);
}
async findOne(
filter: FilterQuery<SuperAdminDocument>,
): Promise<SuperAdminDocument | null> {
return this.superAdminModel.findOne(filter).lean() as any;
}
async findByLoginIdentifier(identifier: string): Promise<SuperAdminDocument | null> {
const id = identifier.trim();
return this.superAdminModel.findOne({ email: id }).lean() as any;
}
async create(data: Partial<SuperAdminModel>): Promise<SuperAdminDocument> {
return this.superAdminModel.create(data);
}
async updateOne(filter: FilterQuery<SuperAdminDocument>, update: any) {
return this.superAdminModel.updateOne(filter, update);
}
async findAll(): Promise<SuperAdminDocument[]> {
return this.superAdminModel.find().lean() as any;
}
}

View File

@@ -0,0 +1,30 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({
collection: "super-admins",
versionKey: false,
timestamps: true,
})
export class SuperAdminModel {
@Prop({ required: true, unique: true, index: true })
email: string;
@Prop({ required: true })
password: string;
@Prop({ required: true, default: RoleEnum.SUPER_ADMIN })
role: RoleEnum;
@Prop()
firstName?: string;
@Prop()
lastName?: string;
createdAt: Date;
}
export type SuperAdminDocument = HydratedDocument<SuperAdminModel>;
export const SuperAdminSchema = SchemaFactory.createForClass(SuperAdminModel);

View File

@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { RoleEnum } from "src/Types&Enums/role.enum";
/**
* Verifies a Bearer JWT and restricts access to `super_admin` role only.
* Use this guard on all endpoints that should be reachable only by the
* super-admin panel.
*/
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException("Token not found");
}
let payload: any;
try {
payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.JWT_SECRET}`,
});
} catch {
throw new UnauthorizedException("Invalid or expired token");
}
if (payload?.role !== RoleEnum.SUPER_ADMIN) {
throw new UnauthorizedException("Super-admin access required");
}
(request as any).user = payload;
(request as any).identity = payload;
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined;
}
}

View File

@@ -0,0 +1,501 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { Types } from "mongoose";
import { SuperAdminGuard } from "./guards/super-admin.guard";
import { SuperAdminService } from "./super-admin.service";
import {
CreateSuperAdminDto,
CreateFieldExpertAdminDto,
CreateRegistrarAdminDto,
CreateCallCenterAgentDto,
} from "./dto/super-admin.dto";
import { ClientDto } from "src/client/dto/create-client.dto";
import {
InsurerRegisterDto,
GenuineRegisterDto,
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
import {
SystemSettingsResponseDto,
UpdateSystemSettingsDto,
} from "src/system-settings/dto/system-settings.dto";
import { SetExternalInquiriesLiveDto } from "src/client/dto/external-inquiries-live.dto";
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "src/expert-insurer/dto/create-insurer-expert.dto";
@ApiTags("super-admin")
@ApiBearerAuth()
@UseGuards(SuperAdminGuard)
@Controller("super-admin")
export class SuperAdminController {
constructor(
private readonly superAdminService: SuperAdminService,
private readonly systemSettingsService: SystemSettingsService,
private readonly expertInsurerService: ExpertInsurerService,
) {}
// ── Super-admin account management ───────────────────────────────────────
@Get("accounts")
@ApiOperation({ summary: "List all super-admin accounts" })
listSuperAdmins() {
return this.superAdminService.listSuperAdmins();
}
@Post("accounts")
@ApiOperation({ summary: "Create an additional super-admin account" })
@ApiBody({ type: CreateSuperAdminDto })
createSuperAdmin(@Body() body: CreateSuperAdminDto) {
return this.superAdminService.createSuperAdmin(body);
}
// ── Client / insurer management ──────────────────────────────────────────
@Post("clients")
@ApiOperation({
summary: "Create a new insurer client",
description: "Registers a new insurer (client) in the platform.",
})
@ApiBody({ type: ClientDto })
createClient(@Body() body: ClientDto) {
return this.superAdminService.createClient(body);
}
@Get("clients")
@ApiOperation({ summary: "List all insurer clients" })
listClients() {
return this.superAdminService.listClients();
}
@Get("clients/names")
@ApiOperation({ summary: "List insurer client names and IDs" })
listClientNames() {
return this.superAdminService.listClientNames();
}
// ── Actor registration ───────────────────────────────────────────────────
@Post("register/insurer")
@ApiOperation({ summary: "Register a new insurer (company) actor" })
@ApiBody({ type: InsurerRegisterDto })
registerInsurer(@Body() body: InsurerRegisterDto) {
return this.superAdminService.registerInsurer(body);
}
@Post("register/genuine")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Register a genuine actor",
description:
"Kept for legacy clients. Use the unified onboarding flow instead.",
})
@ApiBody({ type: GenuineRegisterDto })
registerGenuine(@Body() body: GenuineRegisterDto) {
return this.superAdminService.registerGenuine(body);
}
@Post("register/legal")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Register a legal actor",
description:
"Kept for legacy clients. Use the unified onboarding flow instead.",
})
@ApiBody({ type: LegalRegisterDto })
registerLegal(@Body() body: LegalRegisterDto) {
return this.superAdminService.registerLegal(body);
}
@Post("create-field-expert")
@ApiOperation({ summary: "Create a field expert" })
@ApiBody({ type: CreateFieldExpertAdminDto })
createFieldExpert(@Body() body: CreateFieldExpertAdminDto) {
return this.superAdminService.createFieldExpert(body);
}
@Post("create-registrar")
@ApiOperation({ summary: "Create a registrar" })
@ApiBody({ type: CreateRegistrarAdminDto })
createRegistrar(@Body() body: CreateRegistrarAdminDto) {
return this.superAdminService.createRegistrar(body);
}
@Post("create-call-center-agent")
@ApiOperation({ summary: "Create a call-center agent for a given client" })
@ApiBody({ type: CreateCallCenterAgentDto })
createCallCenterAgent(@Body() body: CreateCallCenterAgentDto) {
return this.superAdminService.createCallCenterAgent(body);
}
// ── System settings ──────────────────────────────────────────────────────
@Get("system-settings")
@ApiOperation({ summary: "Get global system settings" })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
getSystemSettings() {
return this.systemSettingsService.getSettingsView();
}
@Patch("system-settings")
@ApiOperation({ summary: "Update global system settings" })
@ApiBody({ type: UpdateSystemSettingsDto })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
updateSystemSettings(@Body() body: UpdateSystemSettingsDto) {
return this.systemSettingsService.updateSettings(body);
}
@Patch("external-inquiries-live")
@ApiOperation({
summary: "Enable or disable live external inquiries globally",
description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. When disabled, all inquiry flows use mocks.",
})
@ApiBody({ type: SetExternalInquiriesLiveDto })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
setExternalInquiriesLive(@Body() body: SetExternalInquiriesLiveDto) {
return this.systemSettingsService.updateSettings({
externalApis: { sandHubUseLiveApi: body?.enabled === true },
});
}
// ── Insurer panel (super-admin proxy) ────────────────────────────────────
@Get("insurer/branches")
@ApiOperation({ summary: "List branches for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiQuery({ name: "search", required: false, type: String })
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({ name: "isActive", required: false, description: "Filter active state (true/false)" })
getInsuranceBranches(
@Query("clientId") clientId: string,
@Query("search") search?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
return this.expertInsurerService.retrieveInsuranceBranches(
clientId,
{ search, from, to, isActive },
);
}
@Post("insurer/branches")
@ApiOperation({ summary: "Add a branch for a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateBranchDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addBranch(
@Body("clientId") clientId: string,
@Body() createBranchDto: CreateBranchDto,
) {
return this.expertInsurerService.addBranch(clientId, createBranchDto);
}
@Put("insurer/branches/:branchId/status")
@ApiOperation({ summary: "Set branch active/inactive for a given client" })
@ApiParam({ name: "branchId" })
@ApiBody({
schema: {
type: "object",
properties: {
clientId: { type: "string", description: "Client ObjectId" },
isActive: { type: "boolean" },
},
required: ["clientId", "isActive"],
},
})
setBranchStatus(
@Param("branchId") branchId: string,
@Body("clientId") clientId: string,
@Body("isActive") isActive: unknown,
) {
let active: boolean;
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);
}
return this.expertInsurerService.setBranchActive(clientId, branchId, active);
}
@Post("insurer/experts/blame")
@ApiOperation({ summary: "Create a blame expert under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateBlameExpertByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addBlameExpert(
@Body("clientId") clientId: string,
@Body() body: CreateBlameExpertByInsurerDto,
) {
return this.expertInsurerService.addBlameExpert(clientId, body);
}
@Post("insurer/experts/claim")
@ApiOperation({ summary: "Create a claim expert under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateClaimExpertByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addClaimExpert(
@Body("clientId") clientId: string,
@Body() body: CreateClaimExpertByInsurerDto,
) {
return this.expertInsurerService.addClaimExpert(clientId, body);
}
@Post("insurer/experts/file-maker")
@ApiOperation({ summary: "Create a FileMaker account under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateFileMakerByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addFileMaker(
@Body("clientId") clientId: string,
@Body() body: CreateFileMakerByInsurerDto,
) {
return this.expertInsurerService.addFileMaker(clientId, body);
}
@Post("insurer/experts/file-reviewer")
@ApiOperation({ summary: "Create a FileReviewer account under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateFileReviewerByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addFileReviewer(
@Body("clientId") clientId: string,
@Body() body: CreateFileReviewerByInsurerDto,
) {
return this.expertInsurerService.addFileReviewer(clientId, body);
}
@Get("insurer/experts/list")
@ApiOperation({ summary: "List all experts of a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number })
getAllExperts(
@Query("clientId") clientId: string,
@Query("page") page: number,
@Query("response_count") count: number,
) {
return this.expertInsurerService.retrieveAllExpertsOfClient(
{ clientKey: clientId },
page,
count,
);
}
@Get("insurer/experts/top")
@ApiOperation({ summary: "Top blame vs claim experts for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getTopExperts(@Query("clientId") clientId: string) {
return this.expertInsurerService.getTopExpertsForClient({ clientKey: clientId });
}
@Get("insurer/files")
@ApiOperation({ summary: "List files (blame + claim merged) for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getAllFiles(
@Query("clientId") clientId: string,
@Query() query: ListQueryV2Dto,
) {
return this.expertInsurerService.retrieveAllFilesOfClient(clientId, query);
}
@Get("insurer/report/unified-file-statuses")
@ApiOperation({ summary: "Unified file status catalog + counts for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
getUnifiedFileStatusReport(
@Query("clientId") clientId: string,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return this.expertInsurerService.getInsurerUnifiedFileStatusReport(
{ clientKey: clientId },
query,
);
}
@Get("insurer/report/status-counts")
@ApiOperation({
summary: "Legacy status counts for a given client",
deprecated: true,
description: "Prefer GET insurer/report/unified-file-statuses.",
})
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
getInsurerStatusReport(
@Query("clientId") clientId: string,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return this.expertInsurerService.getInsurerFileStatusCounts(
{ clientKey: clientId },
from,
to,
);
}
@Get("insurer/files/:publicId/timeline")
@ApiOperation({ summary: "Activity timeline for a case of a given client" })
@ApiParam({ name: "publicId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getFileTimeline(
@Query("clientId") clientId: string,
@Param("publicId") publicId: string,
) {
return this.expertInsurerService.getFileTimeline(clientId, publicId);
}
@Get("insurer/files/:publicId")
@ApiOperation({ summary: "File details by publicId for a given client" })
@ApiParam({ name: "publicId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getFileDetailsByPublicId(
@Query("clientId") clientId: string,
@Param("publicId") publicId: string,
) {
return this.expertInsurerService.retrieveFileDetailsByPublicId(clientId, publicId);
}
// @Put("insurer/files/:publicId/rating")
// @ApiOperation({ summary: "Rate experts by publicId for a given client" })
// @ApiParam({ name: "publicId" })
// @ApiBody({
// schema: {
// type: "object",
// required: [
// "clientId",
// "collisionMethodAccuracy",
// "evaluationTimeliness",
// "accidentCauseAccuracy",
// "guiltyVehicleIdentification",
// "botRating",
// ],
// properties: {
// clientId: { type: "string", description: "Client ObjectId" },
// collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5 },
// evaluationTimeliness: { type: "number", minimum: 0, maximum: 5 },
// accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5 },
// guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5 },
// botRating: { type: "number", minimum: 0, maximum: 5 },
// },
// },
// })
// rateExpertsByPublicId(
// @Param("publicId") publicId: string,
// @Body() body: FileRating & { clientId: string },
// ) {
// const { clientId, ...rating } = body;
// return this.expertInsurerService.rateExpertByPublicId(publicId, rating as FileRating, clientId);
// }
@Get("insurer/top-files")
@ApiOperation({ summary: "Top-rated files for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getTopFiles(@Query("clientId") clientId: string) {
return this.expertInsurerService.getTopFilesForClient(clientId);
}
@Get("insurer/statistics")
@ApiOperation({ summary: "Expert statistics report for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getExpertStatistics(@Query("clientId") clientId: string) {
return this.expertInsurerService.getExpertStatisticsReport({ clientKey: clientId });
}
@Get("insurer/:expertId")
@ApiOperation({ summary: "Files handled by one roster expert for a given client" })
@ApiParam({ name: "expertId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getExpertFiles(
@Query("clientId") clientId: string,
@Param("expertId") expertId: string,
) {
if (!Types.ObjectId.isValid(expertId)) {
throw new BadRequestException("Invalid expert ID");
}
return this.expertInsurerService.getAllFilesForInsurerExpert(expertId, clientId);
}
}

View File

@@ -0,0 +1,36 @@
import { Module } from "@nestjs/common";
import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { HashModule } from "src/utils/hash/hash.module";
import { UsersModule } from "src/users/users.module";
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
import { SuperAdminController } from "./super-admin.controller";
import { SuperAdminService } from "./super-admin.service";
import { SuperAdminGuard } from "./guards/super-admin.guard";
/**
* Super-admin feature module.
*
* The `SuperAdminDbService` and its Mongoose model are registered in `AuthModule`
* (which is `@Global()`) to keep login routing central. This module only needs
* to import the other feature modules it proxies.
*
* Dependency chain:
* SuperAdminGuard ← JwtService (global via AuthModule JwtModule)
* SuperAdminService ← ActorAuthService (global), ClientService (ClientModule),
* SuperAdminDbService (global via AuthModule),
* FieldExpertDbService + RegistrarDbService (UsersModule)
*/
@Module({
imports: [
ClientModule,
SystemSettingsModule,
HashModule,
UsersModule,
ExpertInsurerModule,
],
controllers: [SuperAdminController],
providers: [SuperAdminService, SuperAdminGuard],
exports: [SuperAdminService, SuperAdminGuard],
})
export class SuperAdminModule {}

View File

@@ -0,0 +1,129 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Types } from "mongoose";
import { HashService } from "src/utils/hash/hash.service";
import { ClientService } from "src/client/client.service";
import { ClientDto } from "src/client/dto/create-client.dto";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import {
InsurerRegisterDto,
GenuineRegisterDto,
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
import { SuperAdminDbService } from "./entities/db-service/super-admin.db.service";
import {
CreateSuperAdminDto,
CreateFieldExpertAdminDto,
CreateRegistrarAdminDto,
CreateCallCenterAgentDto,
} from "./dto/super-admin.dto";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
@Injectable()
export class SuperAdminService {
constructor(
private readonly superAdminDbService: SuperAdminDbService,
private readonly hashService: HashService,
private readonly clientService: ClientService,
private readonly actorAuthService: ActorAuthService,
private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly callCenterAgentDbService: CallCenterAgentDbService,
) {}
/** List all super-admin accounts (sans password). */
async listSuperAdmins() {
const admins = await this.superAdminDbService.findAll();
return admins.map(({ password: _pw, ...rest }) => rest);
}
/** Create an additional super-admin account. */
async createSuperAdmin(body: CreateSuperAdminDto) {
const email = body.email.toLowerCase().trim();
const existing = await this.superAdminDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A super-admin with this email already exists.",
);
}
const password = await this.hashService.hash(body.password);
const created = await this.superAdminDbService.create({
email,
password,
role: RoleEnum.SUPER_ADMIN,
firstName: body.firstName,
lastName: body.lastName,
});
const { password: _pw, ...rest } = (created as any).toObject
? (created as any).toObject()
: (created as any);
return rest;
}
// ── Client management ────────────────────────────────────────────────────
async createClient(body: ClientDto) {
return this.clientService.addClient(body);
}
async listClients() {
return this.clientService.getClients();
}
async listClientNames() {
return this.clientService.getClientList();
}
// ── Actor registration proxies ───────────────────────────────────────────
async registerInsurer(body: InsurerRegisterDto) {
return this.actorAuthService.insurerRegister(body);
}
async registerGenuine(body: GenuineRegisterDto) {
return this.actorAuthService.genuineRegister(body);
}
async registerLegal(body: LegalRegisterDto) {
return this.actorAuthService.legalRegister(body);
}
async createFieldExpert(body: CreateFieldExpertAdminDto) {
return this.actorAuthService.createFieldExpertMock(body);
}
async createRegistrar(body: CreateRegistrarAdminDto) {
return this.actorAuthService.createRegistrarMock(body);
}
async createCallCenterAgent(body: CreateCallCenterAgentDto) {
const email = body.email.toLowerCase().trim();
const existing = await this.callCenterAgentDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A call-center agent with this email already exists.",
);
}
const password = await this.hashService.hash(body.password);
const created = await this.callCenterAgentDbService.create({
email,
password,
firstName: body.firstName,
lastName: body.lastName,
clientKey: new Types.ObjectId(body.clientId),
mobile: body.mobile,
role: RoleEnum.CALL_CENTER,
});
const { password: _pw, ...rest } = (created as any).toObject
? (created as any).toObject()
: (created as any);
return rest;
}
}

View File

@@ -23,7 +23,7 @@ export class SystemSettingsController {
@Get()
@UseGuards(SettingsJwtGuard, RolesGuard)
@Roles(RoleEnum.ADMIN, RoleEnum.COMPANY)
@Roles(RoleEnum.ADMIN, RoleEnum.COMPANY, RoleEnum.SUPER_ADMIN)
@ApiOperation({
summary: "Get global system settings (external API toggles)",
description:
@@ -36,7 +36,7 @@ export class SystemSettingsController {
@Patch()
@UseGuards(SettingsJwtGuard, RolesGuard)
@Roles(RoleEnum.ADMIN)
@Roles(RoleEnum.ADMIN, RoleEnum.SUPER_ADMIN)
@ApiOperation({
summary: "Update global system settings",
description:

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