1
0
forked from Yara724/api

Compare commits

...

449 Commits

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 16:11:39 +03:30
SepehrYahyaee
a7849f915a Added registrar mirrored 2026-06-15 15:58:31 +03:30
SepehrYahyaee
19dc2a76f2 Added expert field mirror flow 2026-06-15 11:27:25 +03:30
SepehrYahyaee
41f81a2f76 Added expert field mirror flow 2026-06-15 11:24:41 +03:30
048398d653 merge upstream 2026-06-13 21:45:20 +03:30
SepehrYahyaee
79905345e5 Fix car-damage links 2026-06-13 17:43:33 +03:30
SepehrYahyaee
0ed7cd7012 Fix car-damage links 2026-06-13 17:43:00 +03:30
1e7b0d7d06 Merge pull request 'Fixing link for some documents' (#120) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#120
2026-06-13 17:25:25 +03:30
SepehrYahyaee
6426233350 Fix links 2026-06-13 17:24:25 +03:30
3e5e9852ad merge upstream 2026-06-13 15:52:20 +03:30
SepehrYahyaee
3d6b9e130c Fix invite second party link 2026-06-13 15:51:38 +03:30
SepehrYahyaee
ec07d42ced Fix invite second party link 2026-06-13 15:49:07 +03:30
407f58f5ee Merge pull request 'Added USER_BASE_PATH env' (#118) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#118
2026-06-13 15:30:21 +03:30
SepehrYahyaee
c8274d8435 Added USER_BASE_PATH env 2026-06-13 15:29:33 +03:30
f0b24dcd26 Merge pull request 'fixed mock data' (#117) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#117
2026-06-13 14:07:40 +03:30
SepehrYahyaee
5414d9717e Fix 2026-06-13 14:07:07 +03:30
SepehrYahyaee
e413991e7c Fixed mock data 2026-06-13 14:05:27 +03:30
b144458943 Merge pull request 'Added API for externalAPI, added env for clients' (#116) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#116
2026-06-13 11:27:13 +03:30
SepehrYahyaee
3abbd45fac Added API for externalAPI, added env for clients 2026-06-13 11:26:33 +03:30
cb47069e90 Merge pull request 'Removed access control for clients' (#115) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#115
2026-06-11 15:08:36 +03:30
3c3b5191fb Removed access control for clients 2026-06-11 15:07:33 +03:30
8053e1a088 Merge pull request 'making smsApiKey for clients not unique' (#114) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#114
2026-06-09 10:56:19 +03:30
SepehrYahyaee
d276c32e87 making smsApiKey for clients not unique 2026-06-09 10:55:28 +03:30
951ff9b50b Merge pull request 'Fixed correct clientId gets replaced in CAR_BODY' (#113) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#113
2026-06-09 10:40:43 +03:30
SepehrYahyaee
a59e4c57a5 Fixed correct clientId gets replaced in CAR_BODY 2026-06-09 10:02:21 +03:30
33c9811f61 Merge pull request 'Fixed visibility rules to match the business rules' (#112) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#112
2026-06-08 10:22:33 +03:30
SepehrYahyaee
cab584410f Fixed visibility rules to match the business rules 2026-06-08 10:22:02 +03:30
c413bd3417 Merge pull request 'Fixed insurer' (#111) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#111
2026-06-07 16:11:10 +03:30
SepehrYahyaee
249c359898 Fixed insurer 2026-06-07 16:10:42 +03:30
393d43c4d2 Merge pull request 'Fixed unified damagedParts + Simplified Captcha' (#110) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#110
2026-06-07 10:34:53 +03:30
SepehrYahyaee
34142942e5 Simplified captcha, Fixed unified damaged parts 2026-06-07 10:34:10 +03:30
SepehrYahyaee
f023d0f3e7 Simplified captcha 2026-06-06 10:45:53 +03:30
2d7afba75d merge upstream 2026-06-04 13:16:34 +03:30
SepehrYahyaee
9ee933cb76 Fixed price-drop 2026-06-03 17:04:16 +03:30
SepehrYahyaee
16c598118d New module for expert field 2026-06-03 16:51:56 +03:30
2b1edd64c1 Merge pull request 'Fix addClient bug' (#109) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#109
2026-06-03 14:01:44 +03:30
SepehrYahyaee
d92231e517 Fix addClient bug 2026-06-03 14:00:40 +03:30
dc14698823 Merge pull request 'Fixed unified data in insurer as well' (#108) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#108
2026-06-03 12:55:43 +03:30
SepehrYahyaee
8236f0440d Fixed unified data in insurer as well 2026-06-03 12:55:15 +03:30
ffcedcd5f1 Merge pull request 'YARA-948, YARA-977, + Bugs' (#107) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#107
2026-06-03 12:31:44 +03:30
SepehrYahyaee
bd5a33e2ba Fixed clientId of claim error 2026-06-03 12:30:42 +03:30
SepehrYahyaee
0b47e8789b YARA-977 2026-06-03 12:23:30 +03:30
SepehrYahyaee
2c810afcb6 YARA-948 2026-06-03 12:05:19 +03:30
SepehrYahyaee
077bae429e Fixed damagedParts unified structure and resend problems 2026-06-03 11:34:21 +03:30
456135ad08 Merge pull request 'script updated , inquiry field added to blame case and claim case' (#106) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#106
2026-06-02 12:33:50 +03:30
eae46c3212 merge upstream 2026-06-02 12:33:13 +03:30
fe82455562 script updated , inquiry field added to blame case and claim case 2026-06-02 12:32:49 +03:30
f2d7e39487 Merge pull request 'refresh script modified' (#105) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#105
2026-06-01 18:14:57 +03:30
8730e9af62 refresh script modified 2026-06-01 18:14:21 +03:30
cf07122710 Merge pull request 'Centralized car body inquiry' (#104) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#104
2026-06-01 16:38:12 +03:30
SepehrYahyaee
a0247d7769 Centralized car body inquiry 2026-06-01 16:37:23 +03:30
fcf3e63f9b Merge pull request 'refresh blame inquirys script added (look at the comments section for env)' (#103) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#103
2026-06-01 16:07:12 +03:30
519d91102f merge upstream 2026-06-01 16:02:06 +03:30
4bc5889ccd refresh blame inquirys script 2026-06-01 16:01:41 +03:30
a47c6f1c96 Merge pull request 'Fixed inquiry of birthdate' (#102) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#102
2026-06-01 14:11:57 +03:30
SepehrYahyaee
5fab0a00b6 Fixed inquiry of birthdate 2026-06-01 14:11:23 +03:30
e650abdf40 Merge pull request 'main' (#101) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#101
2026-06-01 13:21:59 +03:30
SepehrYahyaee
6261af8a29 Fixed lock 2026-06-01 13:21:32 +03:30
SepehrYahyaee
fde6464739 YARA-951 2026-06-01 13:06:09 +03:30
a07b5c3c1e Merge pull request 'Fixed sheba' (#100) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#100
2026-06-01 12:47:22 +03:30
SepehrYahyaee
06af79fa47 Fixed sheba 2026-06-01 12:46:19 +03:30
1a8181a872 Merge pull request 'YARA-972' (#99) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#99
2026-06-01 12:07:32 +03:30
SepehrYahyaee
4a189ba4ef YARA-972 2026-06-01 11:49:25 +03:30
859244940c Merge pull request 'Centralized damaged parts alongside all their required info' (#98) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#98
2026-06-01 11:38:55 +03:30
SepehrYahyaee
cec349b7c2 Centralized damaged parts alongside all their required info 2026-06-01 11:38:30 +03:30
8d8f76eadd Merge pull request 'Reactivated inquiry of birth date' (#97) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#97
2026-06-01 09:39:40 +03:30
SepehrYahyaee
b9e7373225 Reactivated inquiry of birth date 2026-06-01 09:38:56 +03:30
6ddb06594b Merge pull request 'Fixed captcha for prod' (#96) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#96
2026-05-31 16:16:30 +03:30
SepehrYahyaee
e90c6a5c50 Fixed captcha for prod 2026-05-31 16:15:55 +03:30
97f26d400f Merge pull request 'Fixed Swagger ui in prod' (#95) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#95
2026-05-31 15:04:56 +03:30
SepehrYahyaee
02a69f3db2 Fixed Swagger ui in prod 2026-05-31 15:03:46 +03:30
c137d6c6c4 Merge pull request 'Fixed Captcha' (#94) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#94
2026-05-31 14:01:51 +03:30
SepehrYahyaee
2b7192151d Fixed Captcha 2026-05-31 14:01:04 +03:30
389133e1c9 Merge pull request 'Added badane API inquiry' (#93) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#93
2026-05-30 17:17:25 +03:30
SepehrYahyaee
768d6d12fe Removed logs 2026-05-30 17:15:35 +03:30
SepehrYahyaee
84b752c6cc External API for badane 2026-05-30 17:15:21 +03:30
0622ceeaf4 Merge pull request 'Fixed resend blame and claim sms start' (#92) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#92
2026-05-30 16:18:49 +03:30
SepehrYahyaee
3b0db0d250 Fixed resend blame and claim sms start 2026-05-30 16:18:11 +03:30
c502adbe76 Merge pull request 'reduced minimum bytes of medias' (#91) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#91
2026-05-30 15:20:06 +03:30
SepehrYahyaee
7aada14551 reduced minimum bytes of medias 2026-05-30 15:19:04 +03:30
b3bf1b85f8 Merge pull request 'main' (#90) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#90
2026-05-30 15:02:35 +03:30
SepehrYahyaee
d6f1cb9eeb Fixed company code error not found 2026-05-30 15:01:53 +03:30
SepehrYahyaee
da298a3350 Showing a valid message while the OTP is still valid, instead of 400 2026-05-30 11:37:09 +03:30
722cbbf5c9 Merge pull request 'Added .env.example' (#89) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#89
2026-05-30 10:55:22 +03:30
SepehrYahyaee
245160bfc2 Added .env.example 2026-05-30 10:54:52 +03:30
9c760d59f7 Merge pull request 'ENV edit, Joi validation' (#88) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#88
2026-05-30 10:38:12 +03:30
SepehrYahyaee
6ac0bf060e Added Joi for schema validation, tidied up envs 2026-05-30 10:37:23 +03:30
10df869efb Tidied up the project 2026-05-30 08:59:57 +03:30
5c372947dd Merge pull request 'Fixed enrichedEvaluation' (#87) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#87
2026-05-26 16:36:29 +03:30
SepehrYahyaee
9003a7abb6 Fixed enrichedEvaluation 2026-05-26 16:35:13 +03:30
a994331439 Merge pull request 'Changed bcrypt to scrypt built-in' (#86) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#86
2026-05-25 16:44:46 +03:30
SepehrYahyaee
ae12049e1b Changed bcrypt to scrypt built-in 2026-05-25 16:44:43 +03:30
02031efdb5 Merge pull request 'Tidied up the packages and unused modules' (#85) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#85
2026-05-25 14:59:51 +03:30
SepehrYahyaee
48cc4d8a8d Tidied up the packages and unused modules 2026-05-25 14:59:37 +03:30
cbbb45378d Merge pull request 'YARA-885, + fixes' (#84) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#84
2026-05-25 14:15:37 +03:30
SepehrYahyaee
680f3c1798 Fixed resendCarParts label_fa's + OTP 2026-05-25 14:13:17 +03:30
SepehrYahyaee
64fa560f73 YARA-951 case-4 2026-05-25 13:11:37 +03:30
SepehrYahyaee
ff94fa35bf YARA-885 2026-05-25 12:01:00 +03:30
037d9fa934 Merge pull request 'YARA-951' (#83) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#83
2026-05-24 13:34:44 +03:30
SepehrYahyaee
2bdd0d507e YARA-951 2026-05-24 13:34:43 +03:30
f60efa52b1 Merge pull request 'FIX Captcha' (#82) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#82
2026-05-24 10:56:37 +03:30
SepehrYahyaee
866696094f FIX Catcha 2026-05-24 10:56:28 +03:30
ed936ad7b1 Merge pull request 'YARA-913' (#81) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#81
2026-05-24 10:12:31 +03:30
SepehrYahyaee
4d4106a8ab Change minimum size of files 2026-05-24 10:11:52 +03:30
SepehrYahyaee
af875a4773 YARA-913 2026-05-24 10:10:17 +03:30
91221a6848 Merge pull request 'FIX FILE SIZES' (#80) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#80
2026-05-23 16:23:15 +03:30
SepehrYahyaee
a31196774c FIX FILE SIZES 2026-05-23 16:23:11 +03:30
d2474d65bc Merge pull request 'YARA-941' (#79) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#79
2026-05-23 15:51:33 +03:30
SepehrYahyaee
94bde88cb6 YARA-941 2026-05-23 14:05:11 +03:30
39c4855b95 Merge pull request 'Deactivated Valiation Whitelist for now' (#78) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#78
2026-05-20 15:27:46 +03:30
SepehrYahyaee
35487ad033 Deactivated Valiation Whitelist for now 2026-05-20 15:27:44 +03:30
3dec22595c Merge pull request 'Fix SMS, and added pagination+sorting+searching for GETs' (#77) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#77
2026-05-20 14:57:31 +03:30
SepehrYahyaee
44723259d6 Fix SMS, and added pagination+sorting+searching for GETs 2026-05-20 14:57:10 +03:30
c96e361990 Merge pull request 'Validation for prices and objection' (#76) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#76
2026-05-20 11:09:25 +03:30
SepehrYahyaee
e4dfe7c572 Validation for prices and objection 2026-05-20 11:08:47 +03:30
511d478064 Merge pull request 'YARA-883 + Side ID fixes' (#75) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#75
2026-05-18 17:15:13 +03:30
SepehrYahyaee
cef684e37f YARA-883 + Side ID fixes 2026-05-18 17:14:45 +03:30
c81157c431 Merge pull request 'YARA-850, YARA-885' (#74) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#74
2026-05-18 11:23:11 +03:30
SepehrYahyaee
e1954cdb37 YARA-885 2026-05-18 11:00:53 +03:30
SepehrYahyaee
7ff3e9fd10 YARA-850 2026-05-18 10:06:14 +03:30
ced8586710 Merge pull request 'YARA-908' (#73) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#73
2026-05-18 09:30:09 +03:30
SepehrYahyaee
f0ba8949cb YARA-908 2026-05-18 09:29:41 +03:30
fb224360ab Merge pull request 'Fixed step for car-capture video' (#72) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#72
2026-05-17 15:39:58 +03:30
SepehrYahyaee
600c6bd7ed Fixed step for car-capture video 2026-05-17 15:39:23 +03:30
129be58cc9 Merge pull request 'Toggle External API' (#71) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#71
2026-05-16 16:23:44 +03:30
SepehrYahyaee
094816ce8f Toggle External API 2026-05-16 16:23:01 +03:30
f2848a6179 Merge pull request 'Fix 404 for invalid email of actors, added APIs for client settings' (#70) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#70
2026-05-16 15:47:59 +03:30
SepehrYahyaee
7797a4ddab Fix 404 for invalid email of actors, added APIs for client settings 2026-05-16 15:47:31 +03:30
09eb6cc5c0 Merge pull request 'Fixed 3 upload documents for capture part' (#69) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#69
2026-05-16 11:16:22 +03:30
SepehrYahyaee
7c76149c95 Fixed 3 upload documents for capture part 2026-05-16 11:16:08 +03:30
d562e09aab Merge pull request 'Fixed timing issue with UTC' (#68) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#68
2026-05-16 10:28:48 +03:30
SepehrYahyaee
2c851725a5 Fixed timing issue with UTC 2026-05-16 10:28:32 +03:30
e4d6246103 Merge pull request 'Fixed persian labels for car angles' (#67) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#67
2026-05-13 13:22:06 +03:30
SepehrYahyaee
b9d15d1ff6 Fixed persian labels for car angles 2026-05-13 13:21:53 +03:30
241634b149 Merge pull request 'YARA-898, YARA-899' (#66) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#66
2026-05-13 09:33:29 +03:30
SepehrYahyaee
7ba3b57cee Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-05-13 09:31:27 +03:30
SepehrYahyaee
5b6409fc2e Added linkToken and linkContext to OTP 2026-05-13 09:23:45 +03:30
fd4cd3128f Merge pull request 'changed logs' (#65) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#65
2026-05-12 13:35:41 +03:30
0d0cec4b20 - Expert-entered amounts on factor validation
- Cap raised to 53,000,000
- Cap error copy
- Repriced all-factor (and any repriced completion)
- Docs added to expert-claim.v2.controller and PATCH 2/expert-claim-validate-factors/:id
2026-05-12 13:32:13 +03:30
SepehrYahyaee
e26c533a52 Fixed bugs 2026-05-12 11:07:58 +03:30
SepehrYahyaee
f75e6a4453 YARA-898 2026-05-12 11:00:18 +03:30
SepehrYahyaee
e89cf107ff YARA-899 2026-05-12 10:26:04 +03:30
07c4e5126a Merge pull request 'Fixed bugs' (#64) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#64
2026-05-10 17:01:23 +03:30
SepehrYahyaee
010846acd9 Fixed bugs 2026-05-10 17:00:41 +03:30
SepehrYahyaee
cc926d4668 deprecated some old APIs + added examples for login 2026-05-10 14:29:32 +03:30
fdb75d52f7 Merge pull request 'YARA-884' (#63) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#63
2026-05-10 14:16:05 +03:30
SepehrYahyaee
186f6c5837 YARA-884 2026-05-10 14:15:38 +03:30
SepehrYahyaee
3fb90cf1c9 YARA-886 2026-05-10 14:04:11 +03:30
5e5ad0e1b3 Merge pull request 'Added sign links and data to expert-claim API' (#62) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#62
2026-05-10 12:42:37 +03:30
SepehrYahyaee
82bb232d28 Added sign links and data to expert-claim API 2026-05-10 12:42:24 +03:30
45392ad268 Merge pull request 'YARA-877' (#61) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#61
2026-05-10 11:44:07 +03:30
SepehrYahyaee
9c62dc4d3a YARA-877 2026-05-10 11:43:28 +03:30
d1bc64bb6d Merge pull request 'Fixed sheba inquiry' (#60) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#60
2026-05-09 17:51:58 +03:30
SepehrYahyaee
be58b7e47e Fixed sheba inquiry 2026-05-09 17:51:31 +03:30
78e86e5745 Merge pull request 'Fixed external API call to personal inquiry' (#59) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#59
2026-05-09 16:17:57 +03:30
SepehrYahyaee
fe163419c0 Fixed personal inquiry 2026-05-09 16:17:28 +03:30
SepehrYahyaee
eb648d8a87 Fixed personal inquiry 2026-05-09 16:17:09 +03:30
b856cb59f9 Merge pull request 'YARA-732' (#58) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#58
2026-05-09 14:00:40 +03:30
SepehrYahyaee
a52b7a0a72 Added catalog endpoints to claim expert panel 2026-05-09 14:00:02 +03:30
SepehrYahyaee
7998649a89 Added external APIs inquiries 2026-05-09 13:48:19 +03:30
SepehrYahyaee
74c91c73b6 Moved OTP to SMS module 2026-05-09 12:36:38 +03:30
SepehrYahyaee
9e2cec5bc3 YARA-732 2026-05-09 12:09:17 +03:30
e95cb4c255 Merge pull request 'Added extra fields for insurer' (#57) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#57
2026-05-09 10:14:27 +03:30
SepehrYahyaee
c1a54baaf0 Added extra fields for insurer 2026-05-09 09:59:01 +03:30
f8193b6622 Merge pull request 'main' (#56) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#56
2026-05-05 14:53:58 +03:30
3e889307a1 merge upstream 2026-05-05 10:13:11 +03:30
Soheil Hajizadeh
972b4b8719 status modfied 2026-05-04 21:19:11 +03:30
96c21294db Merge pull request 'Fixed claim/blame damagedParts' (#55) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#55
2026-05-03 13:56:41 +03:30
c579f8fa1d Fixed claim/blame damagedParts 2026-05-03 13:54:48 +03:30
c2d59112cf Merge pull request 'user owner guidence added + status and steps fixed' (#54) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#54
2026-05-02 01:54:00 +03:30
Soheil Hajizadeh
e1115b0632 user owner guidence added + status and steps fixed 2026-05-02 01:50:45 +03:30
908292b0c3 Merge pull request 'Fix steps' (#53) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#53
2026-05-01 18:16:50 +03:30
55ec6f1fd1 Fix steps 2026-05-01 17:52:25 +03:30
d33cff8438 Merge pull request 'Fix workflow steps' (#52) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#52
2026-05-01 16:01:53 +03:30
0bb13f4596 Fix workflow steps 2026-05-01 16:01:08 +03:30
70306d42e0 Merge pull request 'Fix damaged-parts flow bug on carAngles' (#51) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#51
2026-05-01 14:45:39 +03:30
d9dc4ecdff Fix damaged-parts flow bug on carAngles 2026-05-01 14:44:28 +03:30
f66fa5d7b4 Merge pull request 'YARA-867, YARA-868' (#50) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#50
2026-05-01 11:38:50 +03:30
6429cb0f2b Fix bugs 2026-05-01 11:37:24 +03:30
6f120b0066 YARA-868 2026-05-01 11:25:10 +03:30
8419ec06ae YARA-867 2026-05-01 11:14:05 +03:30
def4023185 Merge pull request 'Fix data returning owner on claimDetails' (#49) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#49
2026-04-30 13:31:28 +03:30
40606fecf1 Fix data returning owner on claimDetails 2026-04-30 13:30:58 +03:30
c567f93e85 Merge pull request 'Added sign endpoint for claim' (#48) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#48
2026-04-30 13:18:04 +03:30
a9846095c1 Added sign endpoint for claim 2026-04-30 13:17:23 +03:30
b6f0e3c821 Merge pull request 'YARA-855, YARA-856' (#47) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#47
2026-04-30 10:34:52 +03:30
bffb8a3b97 YARA-855 2026-04-30 10:32:53 +03:30
715a9f2467 YARA-856 2026-04-30 09:57:28 +03:30
3813c2b3e3 Merge pull request 'YARA-854, YARA-848, YARA-850' (#46) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#46
2026-04-29 20:24:00 +03:30
ebf8a9a624 YARA-850 2026-04-29 20:22:43 +03:30
993d809de2 YARA-854 2026-04-29 14:26:04 +03:30
80ef885d3b Merge pull request 'Fixed label_fa' (#45) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#45
2026-04-28 16:33:36 +03:30
SepehrYahyaee
3f608f63f1 Fixed label_fa 2026-04-28 16:32:27 +03:30
6019c9e954 Merge pull request 'YARA-853, YARA-857, YARA-858 and a couple of fixes' (#44) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#44
2026-04-28 15:40:31 +03:30
SepehrYahyaee
8ae6f2c91b Fix get details for insurer 2026-04-28 15:39:06 +03:30
SepehrYahyaee
f999313476 YARA-853 2026-04-28 15:22:04 +03:30
SepehrYahyaee
98f1d2caf5 YARA-857 2026-04-28 14:41:47 +03:30
SepehrYahyaee
bbd83da2d5 YARA-858 2026-04-28 14:27:12 +03:30
SepehrYahyaee
9296795166 Added factorLink and branchName support 2026-04-28 13:31:02 +03:30
SepehrYahyaee
f456443342 Fixed blame resend 2026-04-28 10:15:03 +03:30
SepehrYahyaee
bcedd8c6f3 Added GET car-other-parts in v2 as well; deprecated old endpoints and reordered swagger documents 2026-04-28 10:01:45 +03:30
8caf13cf18 Merge pull request 'YARA-833' (#43) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#43
2026-04-27 17:04:18 +03:30
4c4b1a1db7 Merge pull request 'Fixed legacy requestedCounts methods and statistics + claimLink address' (#42) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#42
2026-04-27 15:30:21 +03:30
323 changed files with 51909 additions and 17161 deletions

100
.env.example Normal file
View File

@@ -0,0 +1,100 @@
# ---------------------------------------------
# 🔧 Application Environment
# ---------------------------------------------
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
# ---------------------------------------------
URL =
USER_BASE_PATH =
BASE_URL_DEV =
# ---------------------------------------------
# 📄 Swagger / API Documentation
# ---------------------------------------------
SWAGGER_USER_DEV =
SWAGGER_PASSWORD_DEV =
# ---------------------------------------------
# 🗄️ Database (MongoDB)
# ---------------------------------------------
MONGO_HOST =
MONGO_PORT =
MONGO_USER =
MONGO_PASS =
MONGO_DB_NAME =
MONGO_OPTIONS =
MONGO_TLS =
MONGO_TLS_ALLOW_INVALID_CERTS =
MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB_NAME}?${MONGO_OPTIONS}'
# ---------------------------------------------
# 🔐 Authentication / Security
# ---------------------------------------------
JWT_SECRET =
JWT_EXPIRY =
CAPTCHA_ENABLED = true
# ---------------------------------------------
# 🧩 SanHub Microservice
# ---------------------------------------------
SANHUB_BASE_URL =
SANHUB_URL_LOGIN =
SANHUB_USERNAME =
SANHUB_PASSWORD =
# ---------------------------------------------
# 🤖 AI Services
# ---------------------------------------------
AI_URL =
AI_URL_V2 =
AI_USERNAME =
AI_PASSWORD =
# ---------------------------------------------
# 📩 SMS
# ---------------------------------------------
SMS_PROVIDER =
SMS_API_KEY =
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
# ---------------------------------------------
AUTO_CLIENT_KEY_ENABLED =
# ---------------------------------------------
# 🔗 Other Internal Services
# ---------------------------------------------
TEJARAT_INQUIRY_EMAIL =
TEJARAT_INQUIRY_PASSWORD =
PARSIAN_API_KEY =
PARSIAN_BASIC_TOKEN =
PARSIAN_SMS_URL =

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

10
LICENSE
View File

@@ -1,10 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View File

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

BIN
assets/Vazirmatn-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

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

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

View File

@@ -1 +0,0 @@
{"1000":{"info":"start","message":"start"},"1001":{"info":"Access Denied Other Actor Lock File","message":""},"1004":{"info":"g","message":""},"1005":{"info":"fSF","message":""},"1006":{"info":"request not found ","message":""}}

View File

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

16042
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "yara724", "name": "yara724",
"version": "0.0.1", "version": "2.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -12,84 +12,69 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"engines": {
"npm": ">=10.0.0",
"node": ">=20.0.0"
},
"dependencies": { "dependencies": {
"@arashioz/errjson-talieh": "^2.2.5", "@nestjs/axios": "^4.0.1",
"@fraybabak/kavenegar_nest": "^1.0.5", "@nestjs/common": "^11.0.17",
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "*", "@nestjs/mongoose": "^11.0.4",
"@nestjs/mongoose": "^10.1.0", "@nestjs/platform-express": "^11.1.11",
"@nestjs/passport": "^10.0.3", "@nestjs/serve-static": "^5.0.5",
"@nestjs/platform-express": "^10.4.15", "@nestjs/swagger": "^11.4.4",
"@nestjs/platform-fastify": "^10.4.15", "axios": "^1.16.1",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/websockets": "^10.4.15",
"@types/uuid": "^10.0.0",
"axios": "^1.9.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.15.1",
"crypto": "^1.0.1", "express": "^5.2.1",
"dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2", "form-data": "^4.0.6",
"jalali-moment": "^3.3.11", "joi": "^18.2.1",
"kavenegar": "^1.1.4",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"nestjs-command": "^3.1.4", "pdfkit": "^0.19.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"short-unique-id": "^5.2.0", "socks-proxy-agent": "^8.0.4",
"standard": "^17.1.2", "svg-captcha": "^1.4.0"
"standardjs": "^1.0.0-alpha",
"uuid": "^11.0.3",
"yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.14", "@eslint/eslintrc": "^3.2.0",
"@nestjs/schematics": "^10.2.3", "@eslint/js": "^9.18.0",
"@nestjs/testing": "^10.4.15", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.10.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.12", "@types/multer": "^2.1.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/yargs": "^17.0.33", "eslint": "^9.18.0",
"@typescript-eslint/eslint-plugin": "^8.18.1", "eslint-config-prettier": "^10.0.1",
"@typescript-eslint/parser": "^8.18.1", "eslint-plugin-prettier": "^5.2.3",
"eslint": "^9.17.0", "globals": "^15.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-standard": "^12.0.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2" "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -102,10 +87,19 @@
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@swc/core"
]
} }
} }

View File

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

View File

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

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

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

View File

@@ -0,0 +1,920 @@
#!/usr/bin/env node
/*
* One-time script to replace mocked blameCases party vehicle inquiry data.
* THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both
* Tejarat third-party block inquiry and car-body inquiry. All cases also
* refresh available party personal inquiries into inquiries.person.
*
* Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes.
*/
const fs = require("fs");
const path = require("path");
const mongoose = require("mongoose");
const loadedEnvFiles = loadEnvFiles(process.env.ENV_FILE || ".env");
const LETTER_TO_NUMBER = {
"الف": 1,
"ب": 2,
"ت": 3,
"ج": 4,
"د": 5,
"س": 6,
"ص": 7,
"ط": 8,
"ع": 9,
"ق": 10,
"ل": 11,
"م": 12,
"ن": 13,
"و": 14,
"ه": 15,
"ی": 16,
"ر": 17,
"ک": 18,
"ژ": 19,
"پ": 20,
"ظ": 24,
"ض": 25,
"ز": 41,
"ش": 42,
"گ": 43,
"ث": 44,
D: 45,
S: 46,
"ح": 47,
"ف": 48,
};
const NUMBER_TO_LETTER = Object.fromEntries(
Object.entries(LETTER_TO_NUMBER).map(([letter, number]) => [String(number), letter]),
);
const config = {
mongoUri: requiredEnv("MONGO_URL", "MONGODB_URI", "DATABASE_URL"),
collectionName: process.env.BLAME_COLLECTION || "blameCases",
mongoDbName: process.env.MONGO_DB_NAME || "",
thirdPartyUrl:
process.env.TEJARAT_THIRD_PARTY_URL ||
"http://82.99.202.245:3027/block-inquiry-tejarat",
carBodyUrl:
process.env.TEJARAT_CAR_BODY_URL ||
"http://82.99.202.245:3027/block-inquiry-tejarat/badane",
personUrl:
process.env.TEJARAT_PERSON_URL ||
"http://82.99.202.245:3027/personal-inquiry/tejarat-no",
thirdPartyToken:
process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "",
carBodyToken:
process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "",
personToken:
process.env.TEJARAT_PERSON_TOKEN || process.env.TEJARAT_TOKEN || "",
rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5),
retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false",
retryCount: Number(process.env.RETRY_COUNT || 3),
retryDelayMs: Number(process.env.RETRY_DELAY_MS || 2000),
dryRun: String(process.env.DRY_RUN ?? "true").toLowerCase() !== "false",
limit: process.env.LIMIT ? Number(process.env.LIMIT) : 0,
publicId: process.env.PUBLIC_ID || "",
};
let lastRequestAt = 0;
main().catch(async (error) => {
console.error("[fatal]", error && error.stack ? error.stack : error);
await mongoose.disconnect().catch(() => undefined);
process.exitCode = 1;
});
async function main() {
validateConfig();
console.log("script runned successfully");
console.log(
"[config] envFiles=" +
(loadedEnvFiles.length ? loadedEnvFiles.join(",") : "none") +
", dbName=" +
(config.mongoDbName || "from-url-or-driver-default") +
", collection=" +
config.collectionName +
", dryRun=" +
config.dryRun +
", rateLimitPerMinute=" +
config.rateLimitPerMinute +
", retryEnabled=" +
config.retryEnabled +
", retryCount=" +
config.retryCount,
);
await mongoose.connect(config.mongoUri, {
autoIndex: false,
dbName: config.mongoDbName || undefined,
});
const collection = mongoose.connection.collection(config.collectionName);
const query = {
type: { $in: ["THIRD_PARTY", "CAR_BODY"] },
};
if (config.publicId) query.publicId = config.publicId;
const totalDocs = await collection.countDocuments(query);
console.log(`total docs that we have to edit: ${totalDocs}`);
const cursor = collection
.find(query, {
projection: {
publicId: 1,
requestNo: 1,
type: 1,
parties: 1,
inquiries: 1,
},
})
.sort({ createdAt: 1, _id: 1 });
if (config.limit > 0) cursor.limit(config.limit);
const summary = {
docsSeen: 0,
docsChanged: 0,
partiesInquired: 0,
partiesSkipped: 0,
partiesFailed: 0,
personInquired: 0,
personSkipped: 0,
personFailed: 0,
};
for await (const doc of cursor) {
summary.docsSeen += 1;
const label = doc.publicId || doc.requestNo || String(doc._id);
const requestId = String(doc._id);
console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`);
const parties = Array.isArray(doc.parties) ? clone(doc.parties) : [];
const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? clone(doc.inquiries) : {};
let docChanged = false;
let inquiriesChanged = false;
for (let index = 0; index < parties.length; index += 1) {
const party = parties[index];
const partyLabel = `${label} parties[${index}] role=${party && party.role ? party.role : "-"}`;
const input = buildInquiryInputs(doc, party);
if (!input.ok) {
summary.partiesSkipped += 1;
console.warn(`[skip] ${partyLabel}: ${input.reason}`);
} else {
let nextParty = party;
let partyChanged = false;
for (const request of input.requests) {
console.log(
`[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`,
);
try {
const inquiryResponse = await inquiryWithRetry(request.type, request.body);
const successful = isInquirySuccessful(request.type, inquiryResponse);
console.log(
`[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`,
);
if (!successful) {
summary.partiesFailed += 1;
continue;
}
nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate);
summary.partiesInquired += 1;
partyChanged = true;
} catch (error) {
summary.partiesFailed += 1;
console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`);
}
}
if (partyChanged) {
parties[index] = nextParty;
docChanged = true;
}
}
const personInput = buildPersonInquiryInput(party);
if (!personInput.ok) {
summary.personSkipped += 1;
console.warn(`[skip] ${partyLabel} person: ${personInput.reason}`);
continue;
}
console.log(`[request] ${partyLabel} type=PERSON body=${JSON.stringify(personInput.body)}`);
try {
const personResponse = await inquiryWithRetry("PERSON", personInput.body);
const successful = isInquirySuccessful("PERSON", personResponse);
console.log(
`[response] ${partyLabel} type=PERSON successful=${successful} body=${JSON.stringify(personResponse)}`,
);
if (!successful) {
summary.personFailed += 1;
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, personResponse);
inquiriesChanged = true;
continue;
}
applyPersonInquiryToCaseInquiries(
inquiries,
party,
index,
true,
normalizePersonResponse(personResponse),
);
summary.personInquired += 1;
inquiriesChanged = true;
} catch (error) {
summary.personFailed += 1;
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, error);
inquiriesChanged = true;
console.error(`[error] ${partyLabel} type=PERSON: ${error.message}`);
}
}
if (!docChanged && !inquiriesChanged) {
console.log(`[doc] ${label} no changes`);
continue;
}
if (config.dryRun) {
console.log(`[dry-run] ${label} would update parties/inquiries`);
continue;
}
const updateSet = {
updatedAt: new Date(),
};
if (docChanged) updateSet.parties = parties;
if (inquiriesChanged) updateSet.inquiries = inquiries;
const result = await collection.updateOne(
{ _id: doc._id },
{
$set: updateSet,
},
);
summary.docsChanged += result.modifiedCount;
console.log(`[update] ${label} matched=${result.matchedCount} modified=${result.modifiedCount}`);
}
await mongoose.disconnect();
console.log(`[done] ${JSON.stringify(summary)}`);
}
function buildInquiryInputs(doc, party) {
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
if (!party.vehicle || typeof party.vehicle !== "object") {
return { ok: false, reason: "party.vehicle is missing" };
}
const plate = extractPlate(party);
const nationalCode =
cleanString(party.person && party.person.nationalCodeOfInsurer) ||
cleanString(party.person && party.person.nationalCodeOfDriver);
if (!plate) return { ok: false, reason: "Plk1/Plk2/Plk3/PlkSrl not found" };
if (!nationalCode) return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
if (!serialLetter) return { ok: false, reason: `no Persian letter mapping for Plk2=${plate.Plk2}` };
if (doc.type === "THIRD_PARTY") {
return {
ok: true,
plate,
requests: [buildThirdPartyRequest(plate, serialLetter, nationalCode)],
};
}
if (doc.type === "CAR_BODY") {
return {
ok: true,
plate,
requests: [
buildThirdPartyRequest(plate, serialLetter, nationalCode),
buildCarBodyRequest(plate, serialLetter, nationalCode),
],
};
}
return { ok: false, reason: `unsupported type=${doc.type}` };
}
function buildThirdPartyRequest(plate, serialLetter, nationalCode) {
return {
type: "THIRD_PARTY",
body: {
leftTwoDigits: String(plate.Plk1),
serialLetter,
threeDigits: String(plate.Plk3),
rightTwoDigits: String(plate.PlkSrl),
nationalCode,
},
};
}
function buildCarBodyRequest(plate, serialLetter, nationalCode) {
return {
type: "CAR_BODY",
body: {
part1: toNumber(plate.Plk1),
part2: serialLetter,
part3: toNumber(plate.Plk3),
part4: toNumber(plate.PlkSrl),
nationalCode,
},
};
}
function buildPersonInquiryInput(party) {
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
const person = party.person && typeof party.person === "object" ? party.person : null;
if (!person) return { ok: false, reason: "party.person is missing" };
const nationalCode =
cleanString(person.nationalCodeOfInsurer) ||
cleanString(person.nationalCodeOfDriver);
const birthDate = firstPresent(
person.insurerBirthday,
person.driverBirthday,
person.birthday,
);
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!nationalCode) {
return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
}
if (!gregorianBirthdate) {
return {
ok: false,
reason: `invalid insurerBirthday/driverBirthday=${cleanString(birthDate)}`,
};
}
return {
ok: true,
body: {
nationalCode,
birthdate: gregorianBirthdate,
},
};
}
function parsePlateId(plateId) {
const value = cleanString(plateId);
if (!value) return null;
const parts = value.split("-").map((part) => normalizePlateNumber(part));
if (parts.length !== 4) return null;
const thirdPartIsLetter = LETTER_TO_NUMBER[parts[2]] !== undefined;
const fourthPartIsLetter = LETTER_TO_NUMBER[parts[3]] !== undefined;
if (thirdPartIsLetter) {
return {
Plk1: parts[1],
Plk2: String(LETTER_TO_NUMBER[parts[2]]),
Plk3: parts[3],
PlkSrl: parts[0],
};
}
if (fourthPartIsLetter) {
return {
Plk1: parts[2],
Plk2: String(LETTER_TO_NUMBER[parts[3]]),
Plk3: parts[1],
PlkSrl: parts[0],
};
}
return null;
}
function extractPlate(party) {
const plateFromPlateId = parsePlateId(party.vehicle && party.vehicle.plateId);
if (plateFromPlateId) return plateFromPlateId;
const candidates = [
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.mapped,
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.raw,
party.vehicle && party.vehicle.inquiry,
party.vehicle,
].filter(Boolean);
for (const candidate of candidates) {
const Plk1 = firstPresent(candidate.Plk1, candidate.platePartOne);
const Plk2 = firstPresent(candidate.Plk2, candidate.plateLetterid, candidate.plateLetterId);
const Plk3 = firstPresent(candidate.Plk3, candidate.platePartThree);
const PlkSrl = firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
if (
Plk1 !== undefined &&
Plk2 !== undefined &&
Plk3 !== undefined &&
PlkSrl !== undefined
) {
return {
Plk1: normalizePlateNumber(Plk1),
Plk2: normalizePlateNumber(Plk2),
Plk3: normalizePlateNumber(Plk3),
PlkSrl: normalizePlateNumber(PlkSrl),
};
}
}
return null;
}
async function inquiryWithRetry(type, body) {
const endpoint = getInquiryEndpoint(type);
const url = endpoint.url;
const token = endpoint.token;
const accept = endpoint.accept;
let lastError;
const maxAttempts = config.retryEnabled ? config.retryCount : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await waitForRateLimit();
try {
console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts);
return await postJson(url, token, body, accept);
} catch (error) {
lastError = error;
console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`);
if (attempt < maxAttempts) {
await sleep(config.retryDelayMs * attempt);
}
}
}
throw lastError;
}
async function postJson(url, token, body, accept = "application/json") {
const response = await fetch(url, {
method: "POST",
headers: {
accept,
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
const text = await response.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
function isInquirySuccessful(type, response) {
if (type === "CAR_BODY") return response && response.isSuccess === true && response.data;
if (type === "PERSON") return response && response.status === 200 && response.data;
return response && response.resultStatus === true;
}
function getInquiryEndpoint(type) {
if (type === "CAR_BODY") {
return { url: config.carBodyUrl, token: config.carBodyToken, accept: "application/json" };
}
if (type === "PERSON") {
return { url: config.personUrl, token: config.personToken, accept: "*/*" };
}
return { url: config.thirdPartyUrl, token: config.thirdPartyToken, accept: "application/json" };
}
function applyInquiryToParty(type, party, response, originalPlate) {
if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate);
return applyThirdPartyInquiryToParty(party, response, originalPlate);
}
function applyThirdPartyInquiryToParty(party, response, originalPlate) {
const next = clone(party);
if (!next.vehicle) next.vehicle = {};
if (!next.insurance) next.insurance = {};
const mapped = normalizeThirdPartyResponse(response, originalPlate);
const plateId = buildPlateId(originalPlate);
next.vehicle.plateId = plateId || next.vehicle.plateId;
next.vehicle.inquiry = {
source: "TEJARAT_BLOCK_INQUIRY",
raw: response,
mapped,
refreshedAt: new Date().toISOString(),
};
if (party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.carBody) {
next.vehicle.inquiry.carBody = party.vehicle.inquiry.carBody;
}
next.vehicle.name = mapped.vehiclePersianName || mapped.MapTypNam || "اطلاعات این گزینه در استعلام موجود نیست";
next.vehicle.type = mapped.persianCarType || mapped.MapUsageName || next.vehicle.type;
next.insurance.policyNumber =
mapped.LastCompanyDocumentNumber || mapped.insuranceNumber || next.insurance.policyNumber;
next.insurance.company = mapped.companyPersianName || next.insurance.company;
next.insurance.financialCeiling = mapped.financeCoverage || next.insurance.financialCeiling;
next.insurance.startDate = mapped.persianStartDate || next.insurance.startDate;
next.insurance.endDate = mapped.persianEndDate || next.insurance.endDate;
return next;
}
function applyCarBodyInquiryToParty(party, response, originalPlate) {
const next = clone(party);
if (!next.vehicle) next.vehicle = {};
if (!next.insurance) next.insurance = {};
const mapped = normalizeCarBodyResponse(response, originalPlate);
const plateId = buildPlateId(originalPlate);
next.vehicle.plateId = plateId || next.vehicle.plateId;
if (!next.vehicle.inquiry || typeof next.vehicle.inquiry !== "object") {
next.vehicle.inquiry = {};
}
next.vehicle.inquiry.carBody = {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: response,
mapped,
refreshedAt: new Date().toISOString(),
};
next.vehicle.name = mapped.vehicleSystemTitle || next.vehicle.name;
next.vehicle.type = mapped.vehicleGroupTitle || next.vehicle.type;
next.insurance.carBodyInsurance = {
policyNumber: mapped.policyNumber,
companyId: mapped.companyId,
companyName: mapped.CompanyName,
insurerName: mapped.insurerName,
insurerNationalCode: mapped.insurerNationalCode,
ownerNationalCode: mapped.ownerNationalCode,
chassisNumber: mapped.chassisNumber,
vin: mapped.vin,
motorNumber: mapped.motorNumber,
vehicleGroup: mapped.vehicleGroupTitle,
vehicleSystem: mapped.vehicleSystemTitle,
startDate: mapped.StartDate,
endDate: mapped.EndDate,
issueDate: mapped.IssueDate,
noLossYearsCount: mapped.noLossYearsCount,
lossDocuments: Array.isArray(mapped.lossDocuments) ? mapped.lossDocuments : [],
hasEndorsement: mapped.hasEndorsement,
};
return next;
}
function applyPersonInquiryToCaseInquiries(inquiries, party, index, has, data, error) {
if (!inquiries.person || typeof inquiries.person !== "object") {
inquiries.person = {};
}
const existingData =
inquiries.person.data && typeof inquiries.person.data === "object" && !Array.isArray(inquiries.person.data)
? inquiries.person.data
: {};
const existingError =
inquiries.person.error && typeof inquiries.person.error === "object" && !Array.isArray(inquiries.person.error)
? inquiries.person.error
: {};
const roleKey = party && party.role ? party.role : `party_${index}`;
const nextData = { ...existingData };
const nextError = { ...existingError };
if (has) {
nextData[roleKey] = data || {};
delete nextError[roleKey];
} else {
nextError[roleKey] = normalizeInquiryError(error);
}
inquiries.person = {
has: Object.keys(nextData).length > 0,
data: nextData,
...(Object.keys(nextError).length > 0 ? { error: nextError } : {}),
updatedAt: new Date(),
};
}
function normalizePersonResponse(response) {
return response && response.data ? response.data : response;
}
function normalizeInquiryError(error) {
if (!error) return undefined;
return {
message: error.message || String(error),
status: error.status,
data: error.data,
};
}
function buildPlateId(plate) {
if (!plate) return "";
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
if (!serialLetter) return "";
return (
cleanString(plate.PlkSrl) +
"-" +
cleanString(plate.Plk1) +
"-" +
serialLetter +
"-" +
cleanString(plate.Plk3)
);
}
function normalizeThirdPartyResponse(response, originalPlate) {
return {
...response,
Plk1: toNumber(originalPlate.Plk1),
Plk2: toNumber(originalPlate.Plk2),
Plk3: toNumber(originalPlate.Plk3),
PlkSrl: toNumber(originalPlate.PlkSrl),
CompanyName: response.companyPersianName,
CompanyCode: response.companyId,
LastCompanyDocumentNumber: response.lastCompanyInsuranceNumber || response.insuranceNumber,
FinancialCvrCptl: response.financeCoverage,
IssueDate: response.hIsuDte || response.persianStartDate,
SatrtDate: response.persianStartDate,
EndDate: response.persianEndDate,
MapTypNam: response.vehiclePersianName,
MapUsageName: response.MapUsageName || response.persianCarType,
UsageField: response.persianCarType,
UsageCode: response.usgCod,
VehicleSystemCode: response.vehSysCod,
CarGroupCode: response.carGrpCod,
EdrsJson: Array.isArray(response.edrSes) ? JSON.stringify(response.edrSes) : response.EdrsJson,
InsuranceFullName: response.fullname,
};
}
function normalizeCarBodyResponse(response, originalPlate) {
const data = response.data || {};
return {
...data,
Plk1: toNumber(originalPlate.Plk1),
Plk2: toNumber(originalPlate.Plk2),
Plk3: toNumber(originalPlate.Plk3),
PlkSrl: toNumber(originalPlate.PlkSrl),
policyNumber: data.printNumber,
CompanyName: data.companyName,
CompanyCode: data.companyId,
LastCompanyDocumentNumber: data.printNumber,
InsuranceFullName: data.insurerName,
IssueDate: data.issueDate,
StartDate: data.beginDate,
SatrtDate: data.beginDate,
EndDate: data.endDate,
EngineNumberField: data.motorNumber,
MtrNum: data.motorNumber,
ChassisNumberField: data.chassisNumber,
ShsNum: data.chassisNumber,
VinNumberField: data.vin,
MapTypNam: data.vehicleGroupTitle,
isSuccess: response.isSuccess,
statusCode: response.statusCode,
message: response.message,
};
}
function jalaliToGregorianDate(input) {
if (input === null || input === undefined) return null;
const raw = normalizeDigits(typeof input === "number" ? String(input) : String(input).trim());
if (!raw) return null;
let year = 0;
let month = 0;
let day = 0;
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
if (separated) {
year = parseInt(separated[1], 10);
month = parseInt(separated[2], 10);
day = parseInt(separated[3], 10);
} else {
const digits = raw.replace(/\D/g, "");
if (digits.length !== 8) return null;
year = parseInt(digits.slice(0, 4), 10);
month = parseInt(digits.slice(4, 6), 10);
day = parseInt(digits.slice(6, 8), 10);
}
if (!year || !month || !day) return null;
if (year >= 1900) {
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
const result = `${year}-${mm}-${dd}`;
return isNaN(new Date(result).getTime()) ? null : result;
}
return jalaliPartsToGregorian(year, month, day);
}
function jalaliPartsToGregorian(jYear, jMonth, jDay) {
const jy = jYear - 979;
const jm = jMonth - 1;
const jd = jDay - 1;
let jDayNo =
365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4);
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
for (let i = 0; i < jm; i += 1) {
jDayNo += jalaliMonthDays[i];
}
jDayNo += jd;
let gDayNo = jDayNo + 79;
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
gDayNo %= 146097;
let leap = true;
if (gDayNo >= 36525) {
gDayNo -= 1;
gy += 100 * Math.floor(gDayNo / 36524);
gDayNo %= 36524;
if (gDayNo >= 365) gDayNo += 1;
else leap = false;
}
gy += 4 * Math.floor(gDayNo / 1461);
gDayNo %= 1461;
if (gDayNo >= 366) {
leap = false;
gDayNo -= 1;
gy += Math.floor(gDayNo / 365);
gDayNo %= 365;
}
const gregorianMonthDays = [
31,
leap ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
for (let i = 0; i < 12; i += 1) {
if (gDayNo < gregorianMonthDays[i]) {
gm = i + 1;
break;
}
gDayNo -= gregorianMonthDays[i];
}
const gd = gDayNo + 1;
const mm = String(gm).padStart(2, "0");
const dd = String(gd).padStart(2, "0");
const result = `${gy}-${mm}-${dd}`;
return isNaN(new Date(result).getTime()) ? null : result;
}
async function waitForRateLimit() {
const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute);
const elapsed = Date.now() - lastRequestAt;
if (lastRequestAt > 0 && elapsed < minDelayMs) {
const waitMs = minDelayMs - elapsed;
console.log(`[rate-limit] waiting ${waitMs}ms`);
await sleep(waitMs);
}
lastRequestAt = Date.now();
}
function validateConfig() {
if (!config.mongoUri) throw new Error("MONGO_URL is required");
if (!Number.isFinite(config.rateLimitPerMinute) || config.rateLimitPerMinute <= 0) {
throw new Error("RATE_LIMIT_PER_MINUTE must be a positive number");
}
if (!Number.isFinite(config.retryCount) || config.retryCount <= 0) {
throw new Error("RETRY_COUNT must be a positive number");
}
if (!config.thirdPartyToken) {
throw new Error("TEJARAT_THIRD_PARTY_TOKEN or TEJARAT_TOKEN is required");
}
if (!config.carBodyToken) {
throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required");
}
if (!config.personToken) {
throw new Error("TEJARAT_PERSON_TOKEN or TEJARAT_TOKEN is required");
}
}
function loadEnvFiles(filePath) {
const candidates = path.isAbsolute(filePath)
? [filePath]
: [
path.resolve(process.cwd(), filePath),
path.resolve(__dirname, "..", filePath),
];
const loaded = [];
for (const candidate of [...new Set(candidates)]) {
if (!fs.existsSync(candidate)) continue;
loadEnvFile(candidate);
loaded.push(candidate);
}
return loaded;
}
function loadEnvFile(filePath) {
const resolved = path.resolve(process.cwd(), filePath);
if (!fs.existsSync(resolved)) return;
const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const equalIndex = trimmed.indexOf("=");
if (equalIndex === -1) continue;
const key = trimmed.slice(0, equalIndex).trim();
let value = trimmed.slice(equalIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (key && process.env[key] === undefined) process.env[key] = value;
}
}
function requiredEnv(...names) {
for (const name of names) {
if (process.env[name]) return process.env[name];
}
return "";
}
function firstPresent(...values) {
return values.find((value) => value !== undefined && value !== null && value !== "");
}
function normalizePlateNumber(value) {
const cleaned = normalizeDigits(cleanString(value));
return cleaned === "" ? value : cleaned;
}
function normalizeDigits(value) {
return cleanString(value).replace(/./g, (char) => {
const code = char.charCodeAt(0);
if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
return char;
});
}
function toNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : value;
}
function cleanString(value) {
return value === undefined || value === null ? "" : String(value).trim();
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,44 @@ export enum ClaimCaseStatus {
// Expert flow // Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT", WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
EXPERT_REVIEWING = "EXPERT_REVIEWING", EXPERT_REVIEWING = "EXPERT_REVIEWING",
/**
* @deprecated Prefer specific post-expert statuses below. Kept for existing DB rows and reads.
* Historically used for all owner/insurer steps after expert pricing.
*/
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL", WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
/** Expert reply has only priced lines (`factorNeeded=false` everywhere). Owner final accept/reject at `INSURER_REVIEW`. Also set after expert finishes repair-factor validation when the case returns to final owner sign-off. */
INSURER_REVIEW_AWAITING_OWNER_SIGN = "INSURER_REVIEW_AWAITING_OWNER_SIGN",
/** Expert reply mixes priced lines and factor-needed lines: owner signs priced lines, then uploads factors (status stays through both sub-steps). */
INSURER_REVIEW_MIXED_FACTORS_PENDING = "INSURER_REVIEW_MIXED_FACTORS_PENDING",
/** Expert reply requires a repair-factor file for every line before cost validation. */
OWNER_REPAIR_FACTOR_UPLOAD_PENDING = "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
/**
* V4 split flow only. FileMaker has uploaded all initial required documents;
* the file is sealed and waiting for a FileReviewer to pick it up (accident fields,
* capture, and final blame video). Transitions to SELECTING_OUTER_PARTS when the
* FileReviewer calls select-outer-parts after submitting accident fields.
*/
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
/**
* V5 split flow only. The claim is fully evaluated and owner has signed;
* the FileMaker who created the file must approve before fanavaran submission.
*/
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
/**
* V5 split flow only. FileMaker rejected the completed claim back to FileReviewer
* for correction (adjust pricing, re-do expert review, back-and-forth with user).
*/
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
// Final states // Final states
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -24,7 +24,10 @@ export enum ClaimWorkflowStep {
/** After user objection: damage experts last priced reply (stored in evaluation.damageExpertReplyFinal) */ /** After user objection: damage experts last priced reply (stored in evaluation.damageExpertReplyFinal) */
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY", EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION", EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
// Insurer approval // Insurer approval
INSURER_REVIEW = "INSURER_REVIEW", INSURER_REVIEW = "INSURER_REVIEW",

View File

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

View File

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

View File

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

View File

@@ -4,15 +4,13 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
Logger,
OnModuleInit, OnModuleInit,
} from "@nestjs/common"; } from "@nestjs/common";
import axios, { AxiosRequestConfig } from "axios"; import { ConfigService } from "@nestjs/config";
import * as FormData from "form-data"; import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
@Injectable() @Injectable()
export class AiService implements OnModuleInit { export class AiService implements OnModuleInit {
private readonly logger = new Logger(AiService.name);
private apiKey: string; private apiKey: string;
private accessToken: string = null; private accessToken: string = null;
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
private readonly loginOptions: AxiosRequestConfig = { private readonly loginOptions: AxiosRequestConfig = {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
url: `${process.env.AI_URL_V2}/auth/login`, url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
data: { data: {
username: process.env.AI_USERNAME, username: this.configService.get<string>("AI_USERNAME"),
password: process.env.AI_PASSWORD, password: this.configService.get<string>("AI_PASSWORD"),
}, },
timeout: 1000, // 30 second timeout timeout: 1000, // TODO: Make this ENV
}; };
constructor(private readonly configService: ConfigService) {}
private get profileOptions(): AxiosRequestConfig { private get profileOptions(): AxiosRequestConfig {
return { return {
method: "GET", method: "GET",
url: `${process.env.AI_URL_V2}/auth/profile`, url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
}, },
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
}; };
} }
constructor() {}
async onModuleInit() { async onModuleInit() {
try { try {
const res = await this.login(); const res = await this.login();
if (res?.accessToken) { // if (res?.accessToken) {
this.logger.verbose("AI Service Authenticated Successfully."); // this.accessToken = res.accessToken;
this.accessToken = res.accessToken; // await this.getApiKey();
await this.getApiKey(); // }
this.logger.log("AI Service initialized and ready.");
} else {
this.logger.warn(
"AI Service Unavailable: Login did not return an access token. Will retry on first request.",
);
}
} catch (error) { } catch (error) {
// Don't prevent app startup if AI service is temporarily unavailable // Don't prevent app startup if AI service is temporarily unavailable
// The service will attempt to re-authenticate when aiRequestImage is called // The service will attempt to re-authenticate when aiRequestImage is called
this.logger.warn(
"AI Service Unavailable: Failed during initial login. Will retry on first request.",
);
this.logger.warn(`Error: ${error.message}`);
// Reset tokens so re-authentication will be attempted // Reset tokens so re-authentication will be attempted
this.accessToken = null; this.accessToken = null;
this.apiKey = null; this.apiKey = null;
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
} }
private async login() { private async login() {
try { // const loginResponse = await axios.request(this.loginOptions);
const loginResponse = await axios.request(this.loginOptions); // return loginResponse.data;
return loginResponse.data;
} catch (err) {
const errorMessage = err.response?.data?.message || err.message || "Unknown error";
const statusCode = err.response?.status || 500;
this.logger.error(`AI login failed: ${errorMessage} (Status: ${statusCode})`);
if (err.response?.data) {
this.logger.error(`AI login error details: ${JSON.stringify(err.response.data, null, 2)}`);
}
throw new HttpException(
`Could not authenticate with AI service: ${errorMessage}`,
statusCode >= 400 && statusCode < 500 ? statusCode : HttpStatus.UNAUTHORIZED,
);
}
} }
private async getApiKey() { private async getApiKey() {
try { // const profileResponse = await axios.request(this.profileOptions);
const profileResponse = await axios.request(this.profileOptions); // this.apiKey = profileResponse.data.apiKey.key;
this.apiKey = profileResponse.data.apiKey.key; // return this.apiKey;
this.logger.log("Successfully retrieved AI gateway API key.");
return this.apiKey;
} catch (err) {
this.logger.error("Failed to retrieve AI API key:", err.message);
throw new HttpException(
"Could not get API key from AI service",
HttpStatus.FAILED_DEPENDENCY,
);
}
} }
public async aiRequestImage(file: { path: string; fileName?: string }): Promise<any> { public async aiRequestImage(file: {
path: string;
fileName?: string;
}): Promise<any> {
// Ensure authentication is set up // Ensure authentication is set up
if (!this.accessToken || !this.apiKey) { if (!this.accessToken || !this.apiKey) {
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
try { try {
const res = await this.login(); const res = await this.login();
if (res?.accessToken) { // if (res?.accessToken) {
this.accessToken = res.accessToken; // this.accessToken = res.accessToken;
await this.getApiKey(); // await this.getApiKey();
} else { // } else {
throw new HttpException( // throw new HttpException(
"AI Service authentication failed", // "AI Service authentication failed",
HttpStatus.UNAUTHORIZED, // HttpStatus.UNAUTHORIZED,
); // );
} // }
} catch (error) { } catch (error) {
this.logger.error("Failed to re-authenticate AI service:", error.message);
throw new HttpException( throw new HttpException(
"AI Service authentication failed", "AI Service authentication failed",
HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED,
@@ -136,114 +103,86 @@ export class AiService implements OnModuleInit {
} }
// Resolve relative paths to absolute paths // Resolve relative paths to absolute paths
const filePath = file.path.startsWith("/") const filePath = file.path.startsWith("/")
? file.path ? file.path
: join(process.cwd(), file.path.replace(/^\.\//, "")); : join(process.cwd(), file.path.replace(/^\.\//, ""));
this.logger.log(`Processing AI image request for: ${filePath}`);
// Check if file exists // Check if file exists
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
this.logger.error(`File not found at path: ${filePath}`);
throw new HttpException( throw new HttpException(
`File not found: ${file.path}`, `File not found: ${file.path}`,
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
const form = new FormData(); // const form = new FormData();
const fileStream = createReadStream(filePath); const fileStream = createReadStream(filePath);
// Append file with filename if available // Append file with filename if available
if (file.fileName) { if (file.fileName) {
form.append("images", fileStream, file.fileName); // form.append("images", fileStream, file.fileName);
} else { } else {
// Extract filename from path if not provided // Extract filename from path if not provided
const pathParts = filePath.split("/"); const pathParts = filePath.split("/");
const extractedFileName = pathParts[pathParts.length - 1]; const extractedFileName = pathParts[pathParts.length - 1];
form.append("images", fileStream, extractedFileName); // form.append("images", fileStream, extractedFileName);
} }
try { try {
const requestHeaders = { const requestHeaders = {
...this.imageProcessOptions.headers, ...this.imageProcessOptions.headers,
...form.getHeaders(), // ...form.getHeaders(),
}; };
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`); const fs = require("fs");
this.logger.log(`[STEP 1/4] File: ${filePath}, Filename: ${file.fileName || 'extracted from path'}`);
this.logger.log(`[STEP 1/4] FormData Content-Type: ${form.getHeaders()['content-type']}`);
this.logger.log(`[STEP 1/4] Authorization header present: ${!!requestHeaders.Authorization}`);
this.logger.log(`[STEP 1/4] Gateway API key present: ${!!this.apiKey}`);
this.logger.log(`[STEP 1/4] Request method: POST`);
this.logger.log(`[STEP 1/4] FormData field name: "images"`);
// Get file stats for debugging
const fs = require('fs');
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
this.logger.log(`[STEP 1/4] File size: ${stats.size} bytes`);
this.logger.log(`[STEP 1/4] File exists: ${existsSync(filePath)}`);
const response = await axios.request({
...this.imageProcessOptions,
headers: requestHeaders,
data: form,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`); // const response = await axios.request({
// ...this.imageProcessOptions,
// headers: requestHeaders,
// // data: form,
// maxContentLength: Infinity,
// maxBodyLength: Infinity,
// });
// Validate response structure // Validate response structure
if (!response.data) { // if (!response.data) {
this.logger.error(`[ERROR] AI response is empty or missing data`); // throw new HttpException(
throw new HttpException( // "AI Service returned empty response",
"AI Service returned empty response", // HttpStatus.BAD_GATEWAY,
HttpStatus.BAD_GATEWAY, // );
); // }
}
// Check for error in response first (AI service returns 201 with error in body) // // Check for error in response first (AI service returns 201 with error in body)
if (response.data.error) { // if (response.data.error) {
this.logger.error(`[ERROR] AI service returned an error in response body`); // throw new HttpException(
this.logger.error(`[ERROR] Error message: ${response.data.error}`); // `AI Service error: ${response.data.error}`,
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`); // HttpStatus.BAD_GATEWAY,
throw new HttpException( // );
`AI Service error: ${response.data.error}`, // }
HttpStatus.BAD_GATEWAY,
);
}
// Check for processed image (downloadLink) // // Check for processed image (downloadLink)
if (!response.data.downloadLink) { // if (!response.data.downloadLink) {
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`); // throw new HttpException(
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`); // "AI Service did not return processed image (downloadLink missing)",
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`); // HttpStatus.BAD_GATEWAY,
throw new HttpException( // );
"AI Service did not return processed image (downloadLink missing)", // }
HttpStatus.BAD_GATEWAY,
);
}
// Check for reports // return response.data;
if (!response.data.reports) {
this.logger.warn(`[WARNING] AI response missing reports object, but downloadLink exists`);
this.logger.warn(`[WARNING] Response keys: ${JSON.stringify(Object.keys(response.data))}`);
}
this.logger.log(`[STEP 3/4] Validated AI response - downloadLink: ${response.data.downloadLink ? 'present' : 'missing'}, reports: ${response.data.reports ? 'present' : 'missing'}`);
return response.data;
} catch (er) { } catch (er) {
// Determine error source // Determine error source
let errorSource = "UNKNOWN"; let errorSource = "UNKNOWN";
let errorMessage = er.message; let errorMessage = er.message;
let errorDetails = "No error details available"; let errorDetails = "No error details available";
if (er.response) { if (er.response) {
errorSource = "AI_SERVICE_RESPONSE"; errorSource = "AI_SERVICE_RESPONSE";
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`; errorMessage =
errorDetails = er.response?.data er.response?.data?.message ||
er.message ||
`HTTP ${er.response.status}`;
errorDetails = er.response?.data
? JSON.stringify(er.response.data, null, 2) ? JSON.stringify(er.response.data, null, 2)
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`; : `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
} else if (er.request) { } else if (er.request) {
@@ -254,15 +193,7 @@ export class AiService implements OnModuleInit {
errorSource = "REQUEST_SETUP_ERROR"; errorSource = "REQUEST_SETUP_ERROR";
errorMessage = er.message || "Error setting up request"; errorMessage = er.message || "Error setting up request";
} }
this.logger.error(`[ERROR] AI request failed - Source: ${errorSource}`);
this.logger.error(`[ERROR] File path: ${filePath}`);
this.logger.error(`[ERROR] Error message: ${errorMessage}`);
this.logger.error(`[ERROR] Error details: ${errorDetails}`);
if (er.stack) {
this.logger.error(`[ERROR] Stack trace: ${er.stack}`);
}
// Re-throw with detailed error information // Re-throw with detailed error information
throw new HttpException( throw new HttpException(
`[${errorSource}] ${errorMessage}`, `[${errorSource}] ${errorMessage}`,

View File

@@ -1,56 +1,54 @@
import { join } from "node:path"; import { join } from "node:path";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
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 { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
import * as dotenv from "dotenv";
import { CommandModule } from "nestjs-command";
import { AiModule } from "./ai/ai.module"; import { AiModule } from "./ai/ai.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
import { ClientModule } from "./client/client.module"; import { ClientModule } from "./client/client.module";
import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module";
import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module";
import { LookupsModule } from "./lookups/lookups.module"; import { LookupsModule } from "./lookups/lookups.module";
import { PlatesModule } from "./plates/plates.module"; import { PlatesModule } from "./plates/plates.module";
import { ProfileModule } from "./profile/profile.module"; import { ProfileModule } from "./profile/profile.module";
import { SandHubModule } from "./sand-hub/sand-hub.module"; import { SandHubModule } from "./sand-hub/sand-hub.module";
import { SystemSettingsModule } from "./system-settings/system-settings.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module"; import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin"; import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { DatabaseModule } from "./core/database/database.module";
dotenv.config(); import { AppConfigModule } from "./core/config/config.module";
dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); import { SuperAdminModule } from "./super-admin/super-admin.module";
import { createHttpModuleOptions } from "./core/config/http-proxy.factory";
@Module({ @Module({
imports: [ imports: [
CommandModule, HttpModule.registerAsync({
ScheduleModule.forRoot(), imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
AppConfigModule,
DatabaseModule,
CronModule, CronModule,
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, "..", "files"), // process.cwd() is always the project/container root (/app in Docker),
// so the volume-mounted /app/files is resolved correctly regardless of
// where the compiled dist files live (__dirname would resolve to
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
rootPath: join(process.cwd(), "files"),
serveRoot: "/files", serveRoot: "/files",
}), }),
MongooseModule.forRoot(
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
{
dbName: "yara724",
autoIndex: true,
user: process.env.MONGO_USER,
pass: process.env.MONGO_PASS,
authMechanism: "SCRAM-SHA-256",
tls: true,
tlsAllowInvalidCertificates: true,
connectionFactory: (connection) => {
applyIranFaTimestampPlugin(connection);
return connection;
},
},
),
UsersModule, UsersModule,
AuthModule, AuthModule,
ClientModule, ClientModule,
@@ -58,16 +56,33 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
PlatesModule, PlatesModule,
RequestManagementModule, RequestManagementModule,
SandHubModule, SandHubModule,
SystemSettingsModule,
ExpertBlameModule, ExpertBlameModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
FanavaranModule,
ExpertClaimModule, ExpertClaimModule,
CaseExpertReportModule,
AiModule, AiModule,
ReportsModule, ReportsModule,
ExpertInsurerModule, ExpertInsurerModule,
LookupsModule, LookupsModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
SuperAdminModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [
{
provide: APP_INTERCEPTOR,
useClass: UnicodeDigitsNormalizeInterceptor,
},
// {
// provide: APP_PIPE,
// useValue: new ValidationPipe({
// transform: true,
// whitelist: true,
// forbidNonWhitelisted: false,
// }),
// },
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -5,17 +5,24 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Query,
Req, Req,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Response } from "express";
import { import {
ApiBody, ApiBody,
ApiAcceptedResponse, ApiAcceptedResponse,
ApiOperation,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
ApiBearerAuth, ApiBearerAuth,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
import { GetCaptchaImageQueryDto } from "src/auth/dto/get-captcha-image-query.dto";
import { import {
ForgetPasswordSendCodeDto, ForgetPasswordSendCodeDto,
ForgetPasswordVerifyCodeDto, ForgetPasswordVerifyCodeDto,
@@ -30,43 +37,113 @@ import {
LegalRegisterDto, LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { ClientKey } from "src/decorators/clientKey.decorator"; import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
@Controller("actor") @Controller("actor")
@ApiTags("actor") @ApiTags("actor")
export class ActorAuthController { export class ActorAuthController {
constructor(private readonly actorAuthService: ActorAuthService) {} constructor(
private readonly actorAuthService: ActorAuthService,
private readonly captchaChallengeService: CaptchaChallengeService,
) {}
@Get("captcha")
@ApiOperation({
summary: "Get a login captcha",
description:
"Issues a new captcha challenge. Returns `captchaId`, `image`, and `expiresAt`. " +
"Send `captchaId` and the typed characters as `captcha` on POST /actor/login.\n\n" +
"Optional `format=raw` returns image/svg+xml for browser preview (same captchaId is in JSON when omitted).",
})
@ApiResponse({ status: 200, type: CaptchaResponseDto })
async getCaptcha(
@Query() query: GetCaptchaImageQueryDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.captchaChallengeService.issue();
if (query.format === "raw") {
const svg = await this.captchaChallengeService.getImageById(
result.captchaId,
);
res.setHeader("X-Captcha-Id", result.captchaId);
res.type("image/svg+xml");
res.send(svg);
return;
}
return result;
}
@Get("captcha/:captchaId/image")
@ApiOperation({
summary: "View captcha image by id",
description: "Returns raw SVG for a previously issued captcha challenge.",
})
async getCaptchaImage(
@Param("captchaId") captchaId: string,
@Res({ passthrough: true }) res: Response,
) {
const svg = await this.captchaChallengeService.getImageById(captchaId);
res.type("image/svg+xml");
res.send(svg);
}
/**
* @deprecated Use the unified actor onboarding flow instead. This endpoint
* will be removed in a future release.
*/
@Post("register/genuine") @Post("register/genuine")
@UseGuards(SuperAdminGuard)
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Genuine actor registration",
description:
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
})
@ApiBody({ type: GenuineRegisterDto }) @ApiBody({ type: GenuineRegisterDto })
async registerGenuine(@Body() body: GenuineRegisterDto) { async registerGenuine(@Body() body: GenuineRegisterDto) {
return await this.actorAuthService.genuineRegister(body); return await this.actorAuthService.genuineRegister(body);
} }
/**
* @deprecated Use the unified actor onboarding flow instead. This endpoint
* will be removed in a future release.
*/
@Post("register/legal") @Post("register/legal")
@UseGuards(SuperAdminGuard)
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Legal actor registration",
description:
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
})
@ApiBody({ type: LegalRegisterDto }) @ApiBody({ type: LegalRegisterDto })
async registerLegal(@Body() body: LegalRegisterDto) { async registerLegal(@Body() body: LegalRegisterDto) {
return await this.actorAuthService.legalRegister(body); return await this.actorAuthService.legalRegister(body);
} }
@Post("register/insurer") @Post("register/insurer")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: InsurerRegisterDto }) @ApiBody({ type: InsurerRegisterDto })
async registerInsurer(@Body() body: InsurerRegisterDto) { async registerInsurer(@Body() body: InsurerRegisterDto) {
return await this.actorAuthService.insurerRegister(body); return await this.actorAuthService.insurerRegister(body);
} }
/** Mock: create a field expert for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-field-expert") @Post("create-field-expert")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateFieldExpertDto }) @ApiBody({ type: CreateFieldExpertDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createFieldExpert(@Body() body: CreateFieldExpertDto) { async createFieldExpert(@Body() body: CreateFieldExpertDto) {
return await this.actorAuthService.createFieldExpertMock(body); return await this.actorAuthService.createFieldExpertMock(body);
} }
/** Mock: create a registrar for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-registrar") @Post("create-registrar")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateRegistrarDto }) @ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) { async createRegistrar(@Body() body: CreateRegistrarDto) {
@@ -76,13 +153,75 @@ export class ActorAuthController {
@UseGuards(LocalActorAuthGuard) @UseGuards(LocalActorAuthGuard)
@Post("login") @Post("login")
@Roles() @Roles()
@ApiOperation({
summary: "Actor login (returns access + refresh tokens)",
description:
'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with password and one of `username` / `email` / `nationalCode`. On success the response contains the JWT pair and the resolved profile.',
})
@ApiBody({ @ApiBody({
type: LoginActorDto, type: LoginActorDto,
description: "user verify otp -- call this api and get a tokens", description:
"Login payload. Pick one of the swagger examples below to see the exact body shape per role.",
examples: {
company: {
summary: "Insurer / company portal",
description:
"Sample tenant credentials for the insurer (company) panel.",
value: {
role: "company",
username: "saman_insurer@gmail.com",
password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
},
},
expert: {
summary: "Blame expert panel",
description: "Sample credentials for a blame (`expert`) account.",
value: {
role: "expert",
username: "blame@gmail.com",
password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
},
},
damage_expert: {
summary: "Damage expert (claim) panel",
description: "Sample credentials for a damage-expert account.",
value: {
role: "damage_expert",
username: "claim@gmail.com",
password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
},
},
field_expert: {
summary: "Field expert panel",
description:
"Login with email+password or nationalCode+password for seeded Parsian field experts.",
value: {
role: "field_expert",
nationalCode: "0051967839",
password: "Parsian@724",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
},
},
},
})
@ApiResponse({
status: 404,
description: "No actor account exists for the given email and role",
})
@ApiResponse({
status: 401,
description: "Wrong password or role mismatch",
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @ClientKey() client) { async login(@Req() req: { user: Record<string, unknown> }) {
return await this.actorAuthService.loginActors(req.user); return req.user;
} }
@Post("forget-password") @Post("forget-password")

View File

@@ -22,11 +22,15 @@ export class UserAuthController {
@Post("/send-otp") @Post("/send-otp")
@ApiBody({ @ApiBody({
type: UserLoginDto, type: UserLoginDto,
description: "user login api -- call this api and send otp", description: "Users can ask for OTP via this API and receive it",
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async sendOtpRq(@Body() body: UserLoginDto) { async sendOtpRq(@Body() body: UserLoginDto) {
const res = await this.userAuthService.sendOtpRequest(body.mobile); const res = await this.userAuthService.sendOtpRequest(body.mobile, {
linkToken: body.linkToken,
linkContext: body.linkContext,
});
if (res) { if (res) {
throw new HttpException(res, HttpStatus.ACCEPTED); throw new HttpException(res, HttpStatus.ACCEPTED);
} }
@@ -36,7 +40,8 @@ export class UserAuthController {
@UseGuards(LocalUserAuthGuard) @UseGuards(LocalUserAuthGuard)
@ApiBody({ @ApiBody({
type: UserVerifyOtp, type: UserVerifyOtp,
description: "user verify otp -- call this api and get a tokens", description:
"Users can send their credentials and get their access token to server",
}) })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @CurrentUser() user) { async login(@Body() body, @Req() req, @CurrentUser() user) {

View File

@@ -18,6 +18,7 @@ import {
RegisterDtoRs, RegisterDtoRs,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { StateListDtoRs } from "src/auth/dto/actor/states.dto"; import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service"; import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum"; import { UserType } from "src/Types&Enums/userType.enum";
@@ -26,9 +27,12 @@ import { DamageExpertDbService } from "src/users/entities/db-service/damage-expe
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { OtpService } from "src/utils/otp/otp.service"; import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -49,9 +53,13 @@ export class ActorAuthService {
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService, private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService, 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 // TODO convrt to class for dynamic controller
@@ -63,7 +71,7 @@ export class ActorAuthService {
res = await this.expertDbService.findOne({ res = await this.expertDbService.findOne({
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else res = await this.expertDbService.findOne({ email: username }); else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
break; break;
case RoleEnum.DAMAGE_EXPERT: case RoleEnum.DAMAGE_EXPERT:
if (username == null && userId) if (username == null && userId)
@@ -71,7 +79,10 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else
res = await this.damageExpertDbService.findOne({ email: username }); res = await this.findActorByLoginIdentifier(
this.damageExpertDbService,
username,
);
break; break;
case RoleEnum.FIELD_EXPERT: case RoleEnum.FIELD_EXPERT:
if (username == null && userId) if (username == null && userId)
@@ -79,7 +90,7 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else
res = await this.fieldExpertDbService.findOne({ email: username }); res = await this.fieldExpertDbService.findByLoginIdentifier(username);
break; break;
case RoleEnum.REGISTRAR: case RoleEnum.REGISTRAR:
if (username == null && userId) if (username == null && userId)
@@ -91,54 +102,150 @@ export class ActorAuthService {
case RoleEnum.COMPANY: case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username }); res = await this.insurerExpertDbService.findOne({ email: username });
break; break;
case RoleEnum.FILE_MAKER:
if (username == null && userId)
res = await this.fileMakerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.fileMakerDbService.findByLoginIdentifier(username);
break;
case RoleEnum.FILE_REVIEWER:
if (username == null && userId)
res = await this.fileReviewerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res =
await this.fileReviewerDbService.findByLoginIdentifier(username);
break;
case RoleEnum.SUPER_ADMIN:
if (username == null && userId)
res = await this.superAdminDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.superAdminDbService.findByLoginIdentifier(username);
break;
case RoleEnum.CALL_CENTER:
if (username == null && userId)
res = await this.callCenterAgentDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res = await this.callCenterAgentDbService.findByLoginIdentifier(username);
break;
default: default:
return null; return null;
} }
return res; return res;
} }
async validateActor(username: string, pass: string, role): Promise<any> { /** Normalizes `role` from login body (string or single-element array). */
const user = await this.dynamicDbController(role, username); parseActorLoginRole(role: unknown): RoleEnum {
if (user) { const raw = Array.isArray(role) ? role[0] : role;
if (user.role !== role) { if (
throw new UnauthorizedException("user not assigned to this role"); typeof raw !== "string" ||
} !Object.values(RoleEnum).includes(raw as RoleEnum)
if (!(await this.hashService.compare(pass, user.password))) { ) {
throw new UnauthorizedException( throw new BadRequestException(
"password is incorrect or access Denied", `Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`,
); );
} else {
return user;
}
} }
return null; return raw as RoleEnum;
} }
async loginActors(user: any) { parseActorLoginUsername(body: Record<string, unknown>): string {
let foundedUser = await this.dynamicDbController(user.role, user.username); const username = body?.username ?? body?.email ?? body?.nationalCode;
if (foundedUser) { if (typeof username !== "string" || !username.trim()) {
const payload = { throw new BadRequestException(
username: foundedUser.username || foundedUser.email, "username, email, or nationalCode is required",
sub: foundedUser._id, );
fullName:
`${foundedUser.firstName || ""} ${foundedUser.lastName || ""}`.trim(),
role: foundedUser.role || "User",
userType: foundedUser.userType || "UserType",
clientKey: foundedUser.clientKey || null,
};
const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`,
expiresIn: "1h",
});
return {
...payload,
access_token: accToken,
};
} else {
throw new UnauthorizedException("expert or damage_expert not found");
} }
return username.trim();
}
private async findActorByLoginIdentifier(
dbService: { findOne: (filter: any) => Promise<any> },
identifier: string,
) {
const id = identifier.trim();
const or: Record<string, string>[] = [{ email: id }, { username: id }];
if (/^\d{10}$/.test(id)) {
or.push({ nationalCode: id });
}
return dbService.findOne({ $or: or });
}
issueActorTokens(actor: {
_id: Types.ObjectId;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
role?: string;
userType?: string;
clientKey?: Types.ObjectId | string | null;
}) {
const payload = {
username:
actor.username || actor.email || (actor as any).nationalCode || null,
sub: actor._id,
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
role: actor.role || "User",
userType: actor.userType || "UserType",
clientKey: actor.clientKey ? String(actor.clientKey) : null,
};
const access_token = this.jwtService.sign(payload, {
secret: `${process.env.JWT_SECRET}`,
expiresIn: "1h",
});
return { ...payload, access_token };
}
async validateActor(
username: string,
pass: string,
role: RoleEnum,
): Promise<any> {
const user = await this.dynamicDbController(role, username);
if (!user) {
throw new NotFoundException("Actor account not found");
}
if (user.role !== role) {
throw new UnauthorizedException("user not assigned to this role");
}
if (!(await this.hashService.compare(pass, user.password))) {
throw new UnauthorizedException("password is incorrect or access Denied");
}
return user;
}
/**
* Authenticates an actor from a login request body (role, username/email, password).
*/
async loginFromCredentials(body: Record<string, unknown>) {
const role = this.parseActorLoginRole(body?.role);
const username = this.parseActorLoginUsername(body);
const password = body?.password;
if (typeof password !== "string" || !password) {
throw new BadRequestException("password is required");
}
const captchaId =
typeof body?.captchaId === "string" ? body.captchaId : undefined;
const captcha =
typeof body?.captcha === "string" ? body.captcha : undefined;
await this.captchaChallengeService.verify(captchaId, captcha);
const actor = await this.validateActor(username, password, role);
return this.issueActorTokens(actor);
}
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
async loginActors(user: any) {
if (user?.access_token && user?.sub) {
return user;
}
return this.loginFromCredentials(user as Record<string, unknown>);
} }
async registerActors( async registerActors(
@@ -173,7 +280,7 @@ export class ActorAuthService {
firstName: body.firstName, firstName: body.firstName,
lastName: body.lastName, lastName: body.lastName,
phone: body.phone, phone: body.phone,
mobile:body.mobile, mobile: body.mobile,
city: body.city, city: body.city,
state: body.state, state: body.state,
address: body.address, address: body.address,
@@ -393,13 +500,7 @@ export class ActorAuthService {
"state", "state",
"address", "address",
], ],
field_expert: [ field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
"firstName",
"lastName",
"email",
"phone",
"mobile",
],
registrar: ["email"], registrar: ["email"],
}; };
@@ -425,7 +526,11 @@ export class ActorAuthService {
} }
// fetch user detail (document or plain object) // fetch user detail (document or plain object)
const document = await this.dynamicDbController(role, currentUser.role === "company" ? currentUser.username : null, userId); const document = await this.dynamicDbController(
role,
currentUser.role === "company" ? currentUser.username : null,
userId,
);
if (!document) throw new NotFoundException("Profile not found"); if (!document) throw new NotFoundException("Profile not found");

View File

@@ -0,0 +1,38 @@
import {
BadRequestException,
UnauthorizedException,
} from "@nestjs/common";
export enum CaptchaAuthErrorCode {
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
CAPTCHA_NOT_FOUND = "CAPTCHA_NOT_FOUND",
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
CAPTCHA_INVALID = "CAPTCHA_INVALID",
}
const messages: Record<CaptchaAuthErrorCode, string> = {
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
"Captcha is required. Request a new captcha image first.",
[CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND]:
"Captcha id was not found. Request a new captcha image.",
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
"Captcha has expired. Request a new captcha image.",
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
};
export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
return {
code,
message: messages[code],
};
}
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
if (
code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED ||
code === CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND
) {
throw new BadRequestException(captchaAuthErrorBody(code));
}
throw new UnauthorizedException(captchaAuthErrorBody(code));
}

View File

@@ -0,0 +1,46 @@
import {
BadRequestException,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
export enum UserAuthErrorCode {
USER_NOT_FOUND = "USER_NOT_FOUND",
OTP_REQUIRED = "OTP_REQUIRED",
OTP_EXPIRED = "OTP_EXPIRED",
OTP_INVALID = "OTP_INVALID",
OTP_REQUEST_TOO_SOON = "OTP_REQUEST_TOO_SOON",
LINK_NOT_FOUND = "LINK_NOT_FOUND",
LINK_MOBILE_MISMATCH = "LINK_MOBILE_MISMATCH",
}
const messages: Record<UserAuthErrorCode, string> = {
[UserAuthErrorCode.USER_NOT_FOUND]: "User not found",
[UserAuthErrorCode.OTP_REQUIRED]: "Please request an OTP first",
[UserAuthErrorCode.OTP_EXPIRED]: "OTP has expired",
[UserAuthErrorCode.OTP_INVALID]: "OTP is invalid",
[UserAuthErrorCode.OTP_REQUEST_TOO_SOON]:
"Wait for expiry time to finish before requesting another OTP",
[UserAuthErrorCode.LINK_NOT_FOUND]: "Linked SMS token was not found",
[UserAuthErrorCode.LINK_MOBILE_MISMATCH]:
"This mobile number is not allowed to use this SMS link",
};
export function userAuthErrorBody(code: UserAuthErrorCode) {
return {
code,
message: messages[code],
};
}
export function throwUserAuthError(code: UserAuthErrorCode): never {
// if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
// throw new BadRequestException(userAuthErrorBody(code));
// }
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
throw new ForbiddenException(userAuthErrorBody(code));
}
throw new UnauthorizedException(userAuthErrorBody(code));
}

View File

@@ -0,0 +1,218 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model, Types } from "mongoose";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema";
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { normalizeIranMobile } from "src/helpers/iran-mobile";
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
@Injectable()
export class UserLinkAccessService {
constructor(
@InjectModel(RequestManagementModel.name)
private readonly requestManagementModel: Model<RequestManagementModel>,
@InjectModel(BlameRequest.name)
private readonly blameRequestModel: Model<BlameRequest>,
@InjectModel(ClaimRequestManagementModel.name)
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
@InjectModel(ClaimCase.name)
private readonly claimCaseModel: Model<ClaimCase>,
private readonly userDbService: UserDbService,
) {}
async assertMobileAllowed(params: {
mobile: string;
linkToken?: string;
linkContext?: string;
}): Promise<void> {
const linkToken = params.linkToken?.trim();
if (!linkToken) return;
const allowedMobiles = await this.resolveAllowedMobiles(
linkToken,
params.linkContext,
);
if (allowedMobiles.length === 0) {
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
}
const normalizedMobile = normalizeIranMobile(params.mobile);
if (
!normalizedMobile ||
!allowedMobiles.some(
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
)
) {
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
}
}
async resolveAllowedMobiles(
linkToken: string,
linkContext?: string,
): Promise<string[]> {
if (!Types.ObjectId.isValid(linkToken)) return [];
const id = new Types.ObjectId(linkToken);
const context = this.normalizeContext(linkContext);
const allowedMobiles = new Set<string>();
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
await Promise.all([
this.requestManagementModel.findById(id).lean().exec(),
this.blameRequestModel.findById(id).lean().exec(),
this.claimRequestManagementModel.findById(id).lean().exec(),
this.claimCaseModel.findById(id).lean().exec(),
]);
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
return Array.from(allowedMobiles);
}
private addLegacyRequestPhones(
allowedMobiles: Set<string>,
legacyRequest: any,
context?: string,
) {
if (!legacyRequest) return;
const shouldAddFirst = !context || this.isFirstContext(context);
const shouldAddSecond = !context || this.isSecondContext(context);
if (shouldAddFirst) {
this.addPhone(
allowedMobiles,
legacyRequest.firstPartyDetails?.firstPartyPhoneNumber,
);
}
if (shouldAddSecond) {
this.addPhone(
allowedMobiles,
legacyRequest.secondPartyDetails?.secondPartyPhoneNumber,
);
}
for (const event of legacyRequest.history || []) {
const metadata = event?.metadata;
if (shouldAddSecond) this.addPhone(allowedMobiles, metadata?.secondPartyPhone);
for (const sent of metadata?.sentTo || []) {
if (!context || this.matchesRoleContext(context, sent?.role)) {
this.addPhone(allowedMobiles, sent?.phoneNumber);
}
}
}
}
private addBlameRequestPhones(
allowedMobiles: Set<string>,
blameRequest: any,
context?: string,
) {
if (!blameRequest) return;
for (const party of blameRequest.parties || []) {
if (context && !this.matchesRoleContext(context, party?.role)) continue;
this.addPhone(allowedMobiles, party?.person?.phoneNumber);
}
}
private async addLegacyClaimOwnerPhone(
allowedMobiles: Set<string>,
claimRequest: any,
) {
if (!claimRequest) return;
const ownerUserId = claimRequest.owner?.userId || claimRequest.userId;
if (!ownerUserId) return;
const ownerUserIdText = String(ownerUserId);
if (claimRequest.blameRequestId) {
const blameRequest = await this.blameRequestModel
.findById(claimRequest.blameRequestId)
.lean()
.exec();
const ownerParty = (blameRequest?.parties || []).find(
(party: any) =>
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
);
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
}
if (Types.ObjectId.isValid(ownerUserIdText)) {
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
}
}
/** V2 `claimCases` — token in `/caseClaim?token=...` SMS links. */
private async addClaimCaseOwnerPhone(
allowedMobiles: Set<string>,
claimCase: any,
) {
if (!claimCase?.owner?.userId) return;
const ownerUserIdText = String(claimCase.owner.userId);
if (claimCase.blameRequestId) {
const blameRequest = await this.blameRequestModel
.findById(claimCase.blameRequestId)
.lean()
.exec();
const ownerParty = (blameRequest?.parties || []).find(
(party: any) =>
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
);
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
}
if (Types.ObjectId.isValid(ownerUserIdText)) {
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
}
}
private addPhone(allowedMobiles: Set<string>, phone?: string) {
const normalized = normalizeIranMobile(phone);
if (normalized) allowedMobiles.add(normalized);
}
private normalizeContext(linkContext?: string): string | undefined {
const ctx = linkContext?.trim().toUpperCase();
if (!ctx) return undefined;
if (ctx === "USER" || ctx === "USER1") return "FIRST";
if (ctx === "USER2") return "SECOND";
if (ctx === "CASECLAIM" || ctx === "CLAIM") return undefined;
return ctx;
}
private matchesRoleContext(context: string, role?: string): boolean {
if (this.isFirstContext(context)) return role === PartyRole.FIRST;
if (this.isSecondContext(context)) return role === PartyRole.SECOND;
return true;
}
private isFirstContext(context: string): boolean {
return ["FIRST", "USER", "USER1", "FIRST_PARTY"].includes(context);
}
private isSecondContext(context: string): boolean {
return ["SECOND", "USER2", "SECOND_PARTY"].includes(context);
}
}

View File

@@ -1,21 +1,33 @@
import { import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
BadRequestException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotAcceptableException,
NotFoundException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Types } from "mongoose"; import { Types } from "mongoose";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import {
buildUserLookupByPhone,
normalizeIranMobile,
} from "src/helpers/iran-mobile";
import {
computeOtpExpireMs,
FAKE_OTP_CODE,
isFakeOtpEnabled,
isOtpExpiryActive,
readOtpExpireMinutesFromEnv,
} from "src/helpers/user-otp-expiry";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { HashService } from "src/utils/hash/hash.service";
export interface LinkBinding {
linkToken?: string;
linkContext?: string;
}
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
export class UserAuthService { export class UserAuthService {
private readonly logger = new Logger(UserAuthService.name); private readonly logger = new Logger(UserAuthService.name);
@@ -24,18 +36,32 @@ export class UserAuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpService, private readonly otpCreator: OtpGeneratorService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userLinkAccessService: UserLinkAccessService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<any> { async validateUser(
const user = await this.userDbService.findOne({ username }); username: string,
if (!user) throw new NotFoundException("user not found"); pass: string,
binding: LinkBinding = {},
): Promise<any> {
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
const now = new Date().getTime(); await this.userLinkAccessService.assertMobileAllowed({
if (user.otp == null) throw new NotAcceptableException("please get otp"); mobile: canonicalMobile,
if (user.otpExpire < now) { linkToken: binding.linkToken,
throw new NotAcceptableException("expire otp"); linkContext: binding.linkContext,
});
const user = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
if (!isOtpExpiryActive(user.otpExpire)) {
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
} }
if (await this.hashService.compare(pass, user.otp)) { if (await this.hashService.compare(pass, user.otp)) {
return user; return user;
@@ -44,45 +70,59 @@ export class UserAuthService {
} }
async login(user: any) { async login(user: any) {
const userId = String(user._id ?? user.id ?? "");
const payload = { const payload = {
username: user.username, username: user.username,
sub: user.id, sub: userId,
role: "user", role: "user",
}; };
const accToken = this.jwtService.sign(payload, { const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
await this.userDbService.findOneAndUpdate( await this.userDbService.findOneAndUpdate(
{ username: user.username }, { username: user.username },
{ {
tokens: { token: accToken }, tokens: { token: accToken },
otp: null, otp: null,
otpExpire: 0,
}, },
); );
return { return {
userId: user._id, userId,
access_token: accToken, access_token: accToken,
}; };
} }
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> { async sendOtpRequest(
const userExist = await this.userDbService.findOne({ mobile: string,
mobile, binding: LinkBinding = {},
): Promise<LoginDtoRs> {
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
if (!canonicalMobile) {
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
}
await this.userLinkAccessService.assertMobileAllowed({
mobile: canonicalMobile,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
}); });
const otp = this.otpCreator.create();
const userExist = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
const otp = this.createOtpForRequest();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2"); const expireMinutes = readOtpExpireMinutesFromEnv();
const expireMinutes = const nowMs = Date.now();
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0 const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, canonicalMobile);
/// create otp request // console.log(`OTP for ${canonicalMobile}: ${otp}`);
const newUser = await this.userDbService.createUser({ const newUser = await this.userDbService.createUser({
mobile, mobile: canonicalMobile,
username: mobile, username: canonicalMobile,
otp: hashOtp, otp: hashOtp,
tokens: { tokens: {
token: "", token: "",
@@ -100,33 +140,50 @@ export class UserAuthService {
}); });
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish"); if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
await this.smsSender(otp, mobile); // throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
const updateTokens = await this.userDbService.findOneAndUpdate( return new LoginDtoRs(userExist, "OTP Still valid");
{
username: userExist.username,
},
{
otp: hashOtp,
otpExpire,
},
);
if (updateTokens) return new LoginDtoRs(userExist);
} }
await this.smsSender(otp, canonicalMobile);
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
await this.userDbService.findOneAndUpdate(
buildUserLookupByPhone(canonicalMobile),
{
otp: hashOtp,
otpExpire,
mobile: canonicalMobile,
username: userExist.username || canonicalMobile,
},
);
return new LoginDtoRs(userExist);
}
private createOtpForRequest(): string {
if (isFakeOtpEnabled()) {
this.logger.warn(
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
);
return FAKE_OTP_CODE;
}
return this.otpCreator.create();
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
if (isFakeOtpEnabled()) {
this.logger.log(
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
);
return;
}
const ok = await this.smsOrchestrationService.sendAuthOtp( const ok = await this.smsOrchestrationService.sendAuthOtp(
mobile, mobile,
otp, otp,
process.env.AUTH_SMS_TEMPLATE, process.env.AUTH_SMS_TEMPLATE,
); );
if (!ok) { if (!ok) {
throw new HttpException( throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
"auth sms send failed",
HttpStatus.BAD_GATEWAY,
);
} }
this.logger.log( this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`, `Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,

View File

@@ -1,42 +1,82 @@
import { Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt"; import { JwtModule, JwtService } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport"; import { MongooseModule } from "@nestjs/mongoose";
import { LocalStrategy } from "src/auth/stratregys/local.strategy"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy"; import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller"; import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller"; import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import {
ClaimCase,
ClaimCaseSchema,
} from "src/claim-request-management/entites/schema/claim-cases.schema";
import {
ClaimRequestManagementModel,
ClaimRequestManagementSchema,
} from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import {
BlameRequest,
BlameRequestSchema,
} from "src/request-management/entities/schema/blame-cases.schema";
import {
RequestManagementModel,
RequestManagementSchema,
} from "src/request-management/entities/schema/request-management.schema";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { CaptchaModule } from "src/captcha/captcha.module";
import { 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()
@Module({ @Module({
imports: [ imports: [
// MailModule, // Mailer disabled not used
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,
OtpModule, CaptchaModule,
PassportModule,
SmsOrchestrationModule, SmsOrchestrationModule,
MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
{ name: BlameRequest.name, schema: BlameRequestSchema },
{
name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema,
},
{ name: ClaimCase.name, schema: ClaimCaseSchema },
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
global: true, global: true,
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}), }),
], ],
providers: [ providers: [
UserAuthService, UserAuthService,
UserLinkAccessService,
ActorAuthService, ActorAuthService,
LocalStrategy,
LocalActorStrategy,
JwtService, JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
SuperAdminDbService,
],
exports: [
UserAuthService,
ActorAuthService,
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
SuperAdminDbService,
], ],
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
controllers: [UserAuthController, ActorAuthController], controllers: [UserAuthController, ActorAuthController],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,15 +1,55 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
export class LoginActorDto { export class LoginActorDto {
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" }) @ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
role: RoleEnum[]; role: RoleEnum[];
@ApiProperty({}) @ApiPropertyOptional({
username: string; description:
"Actor email or username. For field experts you may also send nationalCode instead.",
})
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({
description: "Alias for username when logging in with email.",
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
example: "4311402422",
description:
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
})
@IsOptional()
@IsString()
nationalCode?: string;
@ApiProperty({}) @ApiProperty({})
password: string; password: string;
@ApiProperty({
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
description: "Captcha id from GET /actor/captcha.",
})
@IsString()
@IsNotEmpty()
@MaxLength(64)
captchaId: string;
@ApiProperty({
example: "a7bx2",
description: "Characters shown in the captcha image.",
})
@IsString()
@IsNotEmpty()
@MaxLength(16)
captcha: string;
} }
export class LoginActorDtoRs extends LoginActorDto { export class LoginActorDtoRs extends LoginActorDto {

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
export class CaptchaResponseDto {
@ApiProperty({
description: "Captcha challenge id — send back with POST /actor/login.",
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
})
captchaId: string;
@ApiProperty({
description: "Sample text",
example: "sb20xe"
})
text: string
@ApiProperty({
description:
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
})
image: string;
@ApiProperty({
description: "Unix timestamp (ms) when this captcha expires.",
example: 1710000000000,
})
expiresAt: number;
}

View File

@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
export class GetCaptchaImageQueryDto {
@ApiPropertyOptional({
enum: ["json", "raw"],
default: "json",
description:
"On GET /actor/captcha, use `raw` to return image/svg+xml (for browser preview). " +
"The JSON response includes `captchaId` either way.",
})
@IsOptional()
@IsIn(["json", "raw"])
format?: "json" | "raw";
}

View File

@@ -1,17 +1,43 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { UserModel } from "src/users/entities/schema/user.schema"; import { UserModel } from "src/users/entities/schema/user.schema";
export class UserLoginDto { export class UserLoginDto {
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
description: "User login dto", description: "Mobile number (username for OTP login)",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(20)
mobile: string; mobile: string;
@ApiPropertyOptional({
example: "65f0c7f0c3f8a2a7c8b3d001",
type: "string",
description: "Raw token from linked SMS URL (?token=...).",
})
@IsOptional()
@IsString()
@MaxLength(128)
linkToken?: string;
@ApiPropertyOptional({
example: "FIRST",
type: "string",
description: "Optional route/context hint for linked SMS login.",
})
@IsOptional()
@IsString()
@MaxLength(64)
linkContext?: string;
} }
export class LoginDtoRs extends UserModel { export class LoginDtoRs extends UserModel {
@ApiProperty({ type: "string" })
message: string; message: string;
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
@@ -37,8 +63,10 @@ export class LoginDtoRs extends UserModel {
username: string; username: string;
mobile: string; mobile: string;
nationalCode: string; nationalCode: string;
constructor(loginData) {
constructor(loginData, message: string = "") {
super(); super();
this.mobile = loginData.mobile; this.mobile = loginData.mobile;
this.message = message;
} }
} }

View File

@@ -1,17 +1,44 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
export class UserVerifyOtp { export class UserVerifyOtp {
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
description: "User login dto", description: "Mobile number (same value sent to send-otp)",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(20)
username: string; username: string;
@ApiProperty({ @ApiProperty({
example: "258567", example: "258567",
type: "string", type: "string",
description: "User login verify dto", description: "OTP code from SMS",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(16)
password: string; password: string;
@ApiPropertyOptional({
example: "65f0c7f0c3f8a2a7c8b3d001",
type: "string",
description: "Raw token from linked SMS URL (?token=...).",
})
@IsOptional()
@IsString()
@MaxLength(128)
linkToken?: string;
@ApiPropertyOptional({
example: "FIRST",
type: "string",
description: "Optional route/context hint for linked SMS login.",
})
@IsOptional()
@IsString()
@MaxLength(64)
linkContext?: string;
} }

View File

@@ -1,41 +1,39 @@
import { import {
CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { AuthGuard } from "@nestjs/passport";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
@Injectable() @Injectable()
export class LocalActorAuthGuard extends AuthGuard("actor") { export class LocalActorAuthGuard implements CanActivate {
constructor( constructor(
private readonly actorAuthService: ActorAuthService, private readonly actorAuthService: ActorAuthService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
) { ) {}
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request); const token = this.extractTokenFromHeader(request);
const path = request.url;
if (!token) { if (!token) {
if (path === "/actor/login") { if (this.isActorLoginRequest(request)) {
const loginData = await this.actorAuthService.loginActors(request.body); const loginData = await this.actorAuthService.loginFromCredentials(
request.body ?? {},
);
request.user = loginData; request.user = loginData;
request.identity = request; request.identity = loginData;
return true; return true;
} else {
throw new UnauthorizedException("Token not found");
} }
throw new UnauthorizedException("Token not found");
} }
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
if ( if (
@@ -45,6 +43,9 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
RoleEnum.COMPANY, RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT, RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR, RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER,
RoleEnum.SUPER_ADMIN,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");
@@ -59,9 +60,21 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
return true; return true;
} }
private extractTokenFromHeader(request: Request): string | undefined { private isActorLoginRequest(request: {
//@ts-ignore url?: string;
const [type, token] = request.headers.authorization?.split(" ") ?? []; path?: string;
route?: { path?: string };
}): boolean {
const path = (request.route?.path ?? request.url ?? request.path ?? "")
.split("?")[0]
.replace(/\/+$/, "");
return path === "/actor/login" || path.endsWith("/actor/login");
}
private extractTokenFromHeader(request: {
headers?: { authorization?: string };
}): string | undefined {
const [type, token] = request.headers?.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined; return type === "Bearer" ? token : undefined;
} }
} }

View File

@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
// Allow users to pass through (they will be checked by service methods) // Allow users to pass through (they will be checked by service methods)
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
throw new UnauthorizedException("Invalid role"); throw new UnauthorizedException("Invalid role");
} catch (error) { } catch (error) {
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) { if (
error instanceof ForbiddenException ||
error instanceof UnauthorizedException
) {
throw error; throw error;
} }
throw new UnauthorizedException(); throw new UnauthorizedException();
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
return type === "Bearer" ? token : undefined; return type === "Bearer" ? token : undefined;
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
/**
* Verifies Bearer JWT for platform settings routes. Does not restrict by role;
* pair with {@link RolesGuard} on handlers.
*/
@Injectable()
export class SettingsJwtGuard 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");
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.JWT_SECRET}`,
});
(request as any).user = payload;
(request as any).identity = payload;
return true;
} catch {
throw new UnauthorizedException("Invalid token");
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined;
}
}

View File

@@ -1,27 +1,30 @@
import { import {
CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
NotAcceptableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport"; import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable() @Injectable()
export class LocalUserAuthGuard extends AuthGuard("local") { export class LocalUserAuthGuard implements CanActivate {
constructor(private readonly userAuthService: UserAuthService) { constructor(private readonly userAuthService: UserAuthService) {}
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { username, password } = request.body; const { username, password, linkToken, linkContext } = request.body ?? {};
let isValidUser = await this.userAuthService.validateUser( const isValidUser = await this.userAuthService.validateUser(
username, username,
password, password,
{ linkToken, linkContext },
); );
if (!isValidUser) { if (!isValidUser) {
throw new NotAcceptableException("otp is wrong"); throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
} }
request["user"] = isValidUser; request.user = isValidUser;
return true; return true;
} }
} }

View File

@@ -1,22 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
@Injectable()
export class LocalActorStrategy extends PassportStrategy(Strategy, "actor") {
constructor(private readonly actorAuthService: ActorAuthService) {
super();
}
// async validate(username, password): Promise<any> {
// const user = await this.actorAuthService.validateActor(
// username,
// password,
// );
// if (!user) {
// throw new UnauthorizedException("user not found");
// }
// return user;
// }
}

View File

@@ -1,19 +0,0 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userAuthService: UserAuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.userAuthService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException("user not found please register");
}
return user;
}
}

View File

@@ -0,0 +1,112 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { randomUUID } from "node:crypto";
import {
CaptchaAuthErrorCode,
throwCaptchaAuthError,
} from "src/auth/auth-services/captcha-auth.error";
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
import { CaptchaService } from "src/captcha/captcha.service";
import { CaptchaChallengeDbService } from "src/captcha/entities/db-service/captcha-challenge.db.service";
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,
private readonly hashService: HashService,
private readonly captchaChallengeDbService: CaptchaChallengeDbService,
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> {
const generated = this.captchaService.generate();
const captchaId = randomUUID();
// Always hash and persist the answer — the env check was the root bug
const answerHash = await this.hashService.hash(
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 + 120_000),
usedAt: null,
});
return {
captchaId,
image: generated.image,
expiresAt: generated.expiresAt,
...(this.isDev && { text: generated.text }),
};
}
async getImageById(captchaId: string): Promise<string> {
const challenge =
await this.captchaChallengeDbService.findByCaptchaId(captchaId);
if (!challenge) {
throw new NotFoundException("Captcha not found");
}
return this.decodeImage(challenge.image);
}
async verify(
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);
}
if (!answer?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}
const challenge = await this.captchaChallengeDbService.findByCaptchaId(
captchaId.trim(),
);
if (!challenge) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND);
}
if (challenge.usedAt) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
if (challenge.expiresAt < Date.now()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
}
const ok = await this.hashService.compare(
this.captchaService.normalizeAnswer(answer),
challenge.answerHash,
);
if (!ok) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
await this.captchaChallengeDbService.markUsed(challenge.captchaId);
}
private decodeImage(imageDataUri: string): string {
const base64 = imageDataUri.replace(/^data:image\/svg\+xml;base64,/, "");
return Buffer.from(base64, "base64").toString("utf8");
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { HashModule } from "src/utils/hash/hash.module";
import { CaptchaChallengeService } from "./captcha-challenge.service";
import { CaptchaService } from "./captcha.service";
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
import {
CaptchaChallenge,
CaptchaChallengeSchema,
} from "./entities/schema/captcha-challenge.schema";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
ConfigModule,
HashModule,
MongooseModule.forFeature([
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
]),
],
providers: [
CaptchaService,
CaptchaChallengeDbService,
CaptchaChallengeService,
],
exports: [CaptchaChallengeService],
})
export class CaptchaModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as svgCaptcha from "svg-captcha";
export interface GeneratedCaptcha {
text: string;
image: string;
expiresAt: number;
}
@Injectable()
export class CaptchaService {
generate(): GeneratedCaptcha {
const captcha = svgCaptcha.create({
size: 5,
ignoreChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
noise: 1,
color: false,
background: "#f8fafc",
width: 160,
height: 56,
fontSize: 52,
});
return {
text: captcha.text,
image: `data:image/svg+xml;base64,${Buffer.from(captcha.data, "utf8").toString("base64")}`,
expiresAt: this.buildExpireAt(),
};
}
normalizeAnswer(input: string): string {
return input.trim().toLowerCase();
}
buildExpireAt(): number {
const raw = Number(
process.env.EXP_CAPTCHA_TIME ?? process.env.EXP_OTP_TIME ?? "2",
);
const minutes = Number.isFinite(raw) && raw > 0 ? raw : 2;
return Date.now() + minutes * 60 * 1000;
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import {
CaptchaChallenge,
CaptchaChallengeDocument,
} from "../schema/captcha-challenge.schema";
@Injectable()
export class CaptchaChallengeDbService {
constructor(
@InjectModel(CaptchaChallenge.name)
private readonly captchaChallengeModel: Model<CaptchaChallengeDocument>,
) {}
create(data: CaptchaChallenge): Promise<CaptchaChallengeDocument> {
return this.captchaChallengeModel.create(data);
}
findByCaptchaId(
captchaId: string,
): Promise<CaptchaChallengeDocument | null> {
return this.captchaChallengeModel.findOne({ captchaId });
}
markUsed(captchaId: string): Promise<CaptchaChallengeDocument | null> {
return this.captchaChallengeModel.findOneAndUpdate(
{ captchaId, usedAt: null },
{ $set: { usedAt: new Date() } },
{ new: true },
);
}
}

View File

@@ -0,0 +1,32 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
@Schema({
collection: "captcha-challenges",
timestamps: true,
versionKey: false,
})
export class CaptchaChallenge {
@Prop({ required: true, unique: true, index: true })
captchaId: string;
@Prop({ required: true })
answerHash: string;
@Prop({ required: true })
image: string;
@Prop({ required: true, index: true })
expiresAt: number;
/** Mongo TTL — document removed shortly after this time. */
@Prop({ required: true, expires: 0 })
expireAt: Date;
@Prop({ default: null })
usedAt?: Date | null;
}
export type CaptchaChallengeDocument = HydratedDocument<CaptchaChallenge>;
export const CaptchaChallengeSchema =
SchemaFactory.createForClass(CaptchaChallenge);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,14 +22,16 @@ import {
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiTags, ApiTags,
ApiOperation,
ApiExcludeController,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
@@ -38,8 +40,9 @@ import { UserObjectionDto } from "./dto/user-objection.dto";
import { InPersonVisitDto } from "./dto/in-person-visit.dto"; import { InPersonVisitDto } from "./dto/in-person-visit.dto";
import { UserRatingDto } from "./dto/user-rating.dto"; import { UserRatingDto } from "./dto/user-rating.dto";
@ApiExcludeController()
@Controller("claim-request-management") @Controller("claim-request-management")
@ApiTags("claim-request-management") // @ApiTags("claim-request-management")
@Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
@UseGuards(ClaimAccessGuard, RolesGuard) @UseGuards(ClaimAccessGuard, RolesGuard)
@ApiBearerAuth() @ApiBearerAuth()
@@ -48,7 +51,8 @@ export class ClaimRequestManagementController {
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
) {} ) {}
@ApiParam({ name: "blameId" }) // @ApiParam({ name: "blameId" })
// @ApiOperation({ deprecated: true })
@Post("/:blameId") @Post("/:blameId")
async createClaimRequest( async createClaimRequest(
@Param("blameId") requestId: string, @Param("blameId") requestId: string,
@@ -61,9 +65,10 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ type: CarDamagePartDto }) // @ApiBody({ type: CarDamagePartDto })
// @ApiOperation({ deprecated: true })
@Patch("/car-part-damage/:claimRequestID") @Patch("/car-part-damage/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async carPartDamage( async carPartDamage(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@Body() body: CarDamagePartDto, @Body() body: CarDamagePartDto,
@@ -76,6 +81,7 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Get("/car-other-part") @Get("/car-other-part")
async getCarOtherParts() { async getCarOtherParts() {
const carOtherPart = await readFile( const carOtherPart = await readFile(
@@ -85,12 +91,12 @@ export class ClaimRequestManagementController {
return carOtherPart; return carOtherPart;
} }
@ApiBody({ type: OtherCarDamagePartDto }) // @ApiBody({ type: OtherCarDamagePartDto })
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-green-cards", destination: "./files/car-green-cards",
@@ -104,6 +110,7 @@ export class ClaimRequestManagementController {
}), }),
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
// @ApiOperation({ deprecated: true })
@Patch("/car-other-part-damage/:claimRequestID") @Patch("/car-other-part-damage/:claimRequestID")
async carOtherPartDamage( async carOtherPartDamage(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -119,35 +126,35 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Get("required-documents-status/:claimRequestID") @Get("required-documents-status/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async getRequiredDocumentsStatus( async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
@Param("claimRequestID") requestId: string,
) {
return await this.claimRequestManagementService.getRequiredDocumentsStatus( return await this.claimRequestManagementService.getRequiredDocumentsStatus(
requestId, requestId,
); );
} }
// @ApiOperation({ deprecated: true })
@Get("car-part-image-required/:claimRequestID") @Get("car-part-image-required/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
async getImageRequired(@Param("claimRequestID") requestId) { async getImageRequired(@Param("claimRequestID") requestId) {
return await this.claimRequestManagementService.getImageRequiredList( return await this.claimRequestManagementService.getImageRequiredList(
requestId, requestId,
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-required-documents/", destination: "./files/claim-required-documents/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -164,13 +171,14 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // // @ApiParam({ name: "claimRequestID" })
@ApiQuery({ // @ApiQuery({
name: "documentType", // name: "documentType",
enum: ClaimRequiredDocumentType, // enum: ClaimRequiredDocumentType,
description: "Type of required document to upload", // description: "Type of required document to upload",
}) // })
// @ApiOperation({ deprecated: true })
@Patch("upload-required-document/:claimRequestID") @Patch("upload-required-document/:claimRequestID")
async uploadRequiredDocument( async uploadRequiredDocument(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -189,17 +197,17 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-parts/", destination: "./files/car-parts/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -217,12 +225,13 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
@ApiParam({ // @ApiParam({
name: "partId", // name: "partId",
description: "The ID of the specific car part being photographed.", // description: "The ID of the specific car part being photographed.",
}) // })
// @ApiOperation({ deprecated: true })
@Patch("capture-car-part-damage/:claimRequestID/:partId") @Patch("capture-car-part-damage/:claimRequestID/:partId")
async captureCarPartDamage( async captureCarPartDamage(
@Param("partId") partId: string, @Param("partId") partId: string,
@@ -239,17 +248,17 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { type: "string", format: "binary" }, // file: { type: "string", format: "binary" },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-capture-videos/", destination: "./files/car-capture-videos/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -261,8 +270,9 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" }) // @ApiParam({ name: "claimRequestID" })
// @ApiOperation({ deprecated: true })
@Patch("car-capture/:claimRequestID") @Patch("car-capture/:claimRequestID")
async captureVideoCapture( async captureVideoCapture(
@Param("claimRequestID") requestId: string, @Param("claimRequestID") requestId: string,
@@ -274,26 +284,26 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Get("requests/") @Get("requests/")
async getRequest(@CurrentUser() currentUser) { async getRequest(@CurrentUser() currentUser) {
return await this.claimRequestManagementService.myRequests(currentUser); return await this.claimRequestManagementService.myRequests(currentUser);
} }
// @ApiOperation({ deprecated: true })
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
myRequests( myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
@Param("claimRequestId") requestId: string,
@CurrentUser() user,
) {
return this.claimRequestManagementService.requestDetails(requestId, user); return this.claimRequestManagementService.requestDetails(requestId, user);
} }
// @ApiOperation({ deprecated: true })
@Put("request/reply/:claimRequestId") @Put("request/reply/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-sign", destination: "./files/claim-sign",
@@ -306,33 +316,34 @@ export class ClaimRequestManagementController {
}), }),
}), }),
) )
@ApiBody({ // @ApiBody({
type: UserCommentDto, // type: UserCommentDto,
description: "if partId null , you can upload video capture", // description: "if partId null , you can upload video capture",
}) // })
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async submitReply( async submitReply(
@Param("claimRequestId") requestId, @Param("claimRequestId") requestId,
@Body() body, @Body() body,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return await this.claimRequestManagementService.submitUserReply( // return await this.claimRequestManagementService.submitUserReply(
requestId, // requestId,
body, // body,
file, // file,
user, // user,
); // );
} }
// @ApiOperation({ deprecated: true })
@Put("request/resend/:claimRequestId/objection") @Put("request/resend/:claimRequestId/objection")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiConsumes("application/json") // @ApiConsumes("application/json")
@ApiBody({ // @ApiBody({
type: UserObjectionDto, // type: UserObjectionDto,
description: "Objection details with optional new parts", // description: "Objection details with optional new parts",
}) // })
async handleUserObjection( async handleUserObjection(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() userObjectionDto: UserObjectionDto, @Body() userObjectionDto: UserObjectionDto,
@@ -343,24 +354,25 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Patch("request/resend/:claimRequestId") @Patch("request/resend/:claimRequestId")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] }) // @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
@ApiQuery({ name: "partId", required: false }) // @ApiQuery({ name: "partId", required: false })
@ApiQuery({ name: "documentName", required: false }) // @ApiQuery({ name: "documentName", required: false })
@ApiQuery({ name: "side", required: false }) // @ApiQuery({ name: "side", required: false })
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { // file: {
type: "string", // type: "string",
format: "binary", // format: "binary",
}, // },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
storage: diskStorage({ storage: diskStorage({
@@ -372,7 +384,7 @@ export class ClaimRequestManagementController {
callback(null, filename); callback(null, filename);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadDocuments( async uploadDocuments(
@@ -393,9 +405,10 @@ export class ClaimRequestManagementController {
* User satisfaction rating for a completed claim file. * User satisfaction rating for a completed claim file.
* Only the damaged user (claim owner) can rate their claim after it is closed. * Only the damaged user (claim owner) can rate their claim after it is closed.
*/ */
// @ApiOperation({ deprecated: true })
@Put("request/:claimRequestId/user-rating") @Put("request/:claimRequestId/user-rating")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UserRatingDto }) // @ApiBody({ type: UserRatingDto })
async addUserRating( async addUserRating(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() ratingDto: UserRatingDto, @Body() ratingDto: UserRatingDto,
@@ -408,21 +421,22 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Patch("request/reply/:claimRequestId/:partId/upload-factor") @Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
@ApiParam({ name: "partId" }) // @ApiParam({ name: "partId" })
@ApiBody({ // @ApiBody({
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
file: { // file: {
type: "string", // type: "string",
format: "binary", // format: "binary",
}, // },
}, // },
}, // },
}) // })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
storage: diskStorage({ storage: diskStorage({
@@ -433,7 +447,7 @@ export class ClaimRequestManagementController {
callback(null, filename); callback(null, filename);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadFactorForPart( async uploadFactorForPart(
@@ -450,8 +464,9 @@ export class ClaimRequestManagementController {
); );
} }
@ApiBody({ type: InPersonVisitDto }) // @ApiBody({ type: InPersonVisitDto })
@ApiParam({ name: "id" }) // @ApiParam({ name: "id" })
// @ApiOperation({ deprecated: true })
@Patch(":id/visit") @Patch(":id/visit")
async inPersonVisit( async inPersonVisit(
@Param("id") requestId: string, @Param("id") requestId: string,
@@ -466,23 +481,25 @@ export class ClaimRequestManagementController {
); );
} }
// @ApiOperation({ deprecated: true })
@Get("branches/:insuranceId") @Get("branches/:insuranceId")
// @ApiParam({ name: "insuranceId" })
async insuranceBranches(@Param("insuranceId") insuranceId: string) { async insuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches( return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
insuranceId,
);
} }
// @ApiOperation({ deprecated: true })
@Get("fanavaran-submit/:claimRequestId") @Get("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) { async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
return await this.claimRequestManagementService.fanavaranSubmit( return await this.claimRequestManagementService.fanavaranSubmit(
claimRequestId, claimRequestId,
); );
} }
// @ApiOperation({ deprecated: true })
@Post("fanavaran-submit/:claimRequestId") @Post("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) // @ApiParam({ name: "claimRequestId" })
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) { async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
return await this.claimRequestManagementService.submitToFanavaran( return await this.claimRequestManagementService.submitToFanavaran(
claimRequestId, claimRequestId,

View File

@@ -1,5 +1,8 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { AiModule } from "src/ai/ai.module"; import { AiModule } from "src/ai/ai.module";
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
@@ -7,11 +10,16 @@ import { UsersModule } from "src/users/users.module";
import { ClaimRequestManagementController } from "./claim-request-management.controller"; import { ClaimRequestManagementController } from "./claim-request-management.controller";
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller"; import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service"; import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema"; import {
ClaimCase,
ClaimCaseSchema,
} from "./entites/schema/claim-cases.schema";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -46,15 +54,26 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
@Module({ @Module({
imports: [ imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: createHttpModuleOptions,
}),
FanavaranAuditModule,
FanavaranLookupModule,
PublicIdModule, PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,
AiModule, AiModule,
SandHubModule, SandHubModule,
ClientModule, ClientModule,
MediaPolicyModule,
JwtModule.register({}), JwtModule.register({}),
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema }, { name: ClaimCase.name, schema: ClaimCaseSchema },
@@ -90,6 +109,8 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequestManagementController, ClaimRequestManagementController,
ClaimRequestManagementV2Controller, ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller, RegistrarClaimV1Controller,
ExpertInitiatedClaimMirrorController,
RegistrarClaimMirrorController,
], ],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,
@@ -98,6 +119,7 @@ import { JwtModule } from "@nestjs/jwt";
DamageImageDbService, DamageImageDbService,
VideoCaptureDbService, VideoCaptureDbService,
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimSignDbService,
], ],
}) })
export class ClaimRequestManagementModule {} export class ClaimRequestManagementModule {}

View File

@@ -2,6 +2,7 @@ import {
Controller, Controller,
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
BadRequestException,
Param, Param,
Query, Query,
Post, Post,
@@ -12,15 +13,28 @@ import {
Get, Get,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
UploadedFiles,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import { readFile } from "node:fs/promises";
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 { diskStorage } from "multer";
import { extname } from "path"; import { extname } from "node:path";
import { Types } from "mongoose";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { 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 { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { import {
@@ -28,15 +42,22 @@ import {
SelectOuterPartsV2Dto, SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto, SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto"; } from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto"; import {
SelectOtherPartsV2Dto,
SelectOtherPartsV2ResponseDto,
} from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto"; import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "./dto/upload-document-v2.dto";
import { import {
CapturePartV2Dto, CapturePartV2Dto,
CapturePartV2ResponseDto, CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto, VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto"; } from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto"; import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto"; import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto"; import { UserRatingDto } from "./dto/user-rating.dto";
@@ -46,25 +67,40 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@Controller("v2/claim-request-management") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT) @Roles(
RoleEnum.USER,
RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER,
)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {} ) {}
@Get("requests") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "Get My Claims (V2)", summary: "Get My Claims (V2)",
description: "Get list of all claim requests for the current user.", description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "List of user claims", description: "List of user claims",
type: GetMyClaimsV2ResponseDto, type: GetMyClaimsV2ResponseDto,
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(
@CurrentUser() user: any,
@Query() query: ListQueryV2Dto,
): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user); return await this.claimRequestManagementService.getMyClaimsV2(
user.sub,
user,
query,
);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -82,7 +118,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get Claim Details (V2)", summary: "Get Claim Details (V2)",
description: description:
"Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.", "Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -127,7 +163,10 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, description: "Claim returned to expert queue" }) @ApiResponse({ status: 200, description: "Claim returned to expert queue" })
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" }) @ApiResponse({
status: 400,
description: "Resend requires uploads or wrong step",
})
async acknowledgeExpertResend( async acknowledgeExpertResend(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any, @CurrentUser() user: any,
@@ -141,44 +180,95 @@ export class ClaimRequestManagementV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to acknowledge expert resend", error instanceof Error
? error.message
: "Failed to acknowledge expert resend",
); );
} }
} }
/** /**
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection). * V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
* Accepts multipart/form-data so optional supporting invoices can be attached in the same request.
*/ */
@Put("request/:claimRequestId/objection") @Put("request/:claimRequestId/objection")
@ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Submit user objection (V2)", summary: "Submit user objection (V2)",
description: description:
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " + "**Windows:** (1) **Insurer-review:** `ClaimCaseStatus` in **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, with `workflow.currentStep=INSURER_REVIEW` and no recorded **final** `evaluation.ownerInsurerApproval` — including **mixed** priced+factor gate (`NEEDS_REVISION` before priced-line signature for factors) or **final** totals (`claimStatus=APPROVED`). Not allowed while uploading factors (`OWNER_UPLOAD_FACTOR_DOCUMENTS`) or expert validation (`EXPERT_COST_EVALUATION`/`EXPERT_VALIDATING_REPAIR_FACTORS`).\n" +
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " + "(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.", "`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`.\n\n" +
"**Invoices:** optionally attach up to 5 supporting documents (images/PDFs) as `invoices` file fields. Stored in `evaluation.objection.invoices[]` and visible to the reviewing expert.",
}) })
@ApiParam({ @ApiParam({
name: "claimRequestId", name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)", description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011", example: "507f1f77bcf86cd799439011",
}) })
@ApiBody({ type: UserObjectionV2Dto }) @ApiBody({
description:
"Objection payload as multipart form fields. `objectionParts` and `newParts` are JSON-encoded strings.",
schema: {
type: "object",
properties: {
objectionParts: {
type: "string",
description:
'JSON-encoded array of disputed priced parts. Example: `[{"partId":201,"reason":"Price too high"}]`',
},
newParts: {
type: "string",
description:
'JSON-encoded array of new parts to add. Example: `[{"partName":"سپر جلو","side":"front"}]`',
},
invoices: {
type: "array",
items: { type: "string", format: "binary" },
description: "Up to 5 supporting invoice or document files (image or PDF).",
},
},
},
})
@ApiResponse({ status: 200, description: "Objection stored" }) @ApiResponse({ status: 200, description: "Objection stored" })
@ApiResponse({ status: 400, description: "No active resend or empty payload" }) @ApiResponse({
status: 400,
description: "No active resend or empty payload",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" }) @ApiResponse({ status: 409, description: "Objection already submitted" })
@UseInterceptors(
FilesInterceptor("invoices", 5, {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-objection-invoices",
filename: (req, file, callback) => {
const unique = Date.now() + "-" + Math.round(Math.random() * 1e6);
const ex = extname(file.originalname);
callback(null, `objection-invoice-${unique}${ex}`);
},
}),
}),
)
async submitUserObjectionV2( async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto, @Body() body: UserObjectionV2Dto,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFiles() invoices?: Express.Multer.File[],
) { ) {
for (const file of invoices ?? []) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
}
try { try {
return await this.claimRequestManagementService.handleUserObjectionV2( return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user, user,
invoices,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -205,7 +295,10 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ type: UserRatingDto }) @ApiBody({ type: UserRatingDto })
@ApiResponse({ status: 200, description: "Rating saved" }) @ApiResponse({ status: 200, description: "Rating saved" })
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" }) @ApiResponse({
status: 400,
description: "Claim not completed or invalid scores",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Rating already submitted" }) @ApiResponse({ status: 409, description: "Rating already submitted" })
@@ -229,6 +322,105 @@ export class ClaimRequestManagementV2Controller {
} }
} }
/**
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
*/
@Put("request/:claimRequestId/owner-insurer-approval/sign")
@ApiParam({
name: "claimRequestId",
description: "Claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Sign priced lines or final claim pricing (owner)",
description:
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
})
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: {
type: "string",
format: "binary",
description: "Signature image",
},
agree: {
type: "boolean",
description: "true to accept expert pricing and complete the claim",
},
branchId: {
type: "string",
description:
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011",
},
},
},
})
@ApiResponse({
status: 200,
description: "Signature stored; claim completed or rejected",
})
@ApiResponse({
status: 400,
description: "Wrong step/status or missing file",
})
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Already signed" })
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 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 submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id");
}
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,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ @ApiParam({
name: "blameRequestId", name: "blameRequestId",
@@ -273,6 +465,49 @@ export class ClaimRequestManagementV2Controller {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
} }
@Get("branches/:insuranceId")
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
})
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@ApiResponse({
status: 200,
description: "List of branches for insurer",
})
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@Get("car-other-part")
@ApiOperation({
summary: "Get other (non-body) parts catalog",
description:
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
})
@ApiResponse({
status: 200,
description: "Other parts catalog",
})
async getCarOtherPartsV2() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Patch("select-outer-parts/:claimRequestId") @Patch("select-outer-parts/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)", summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
@@ -307,8 +542,7 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ @ApiBody({
type: SelectOuterPartsV2Dto, type: SelectOuterPartsV2Dto,
description: description: "Selected vehicle type + selected outer part IDs from catalog",
"Selected vehicle type + selected outer part IDs from catalog",
examples: { examples: {
example1: { example1: {
summary: "Sedan - minor front damage", summary: "Sedan - minor front damage",
@@ -376,9 +610,7 @@ export class ClaimRequestManagementV2Controller {
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error error instanceof Error ? error.message : "Failed to select outer parts",
? error.message
: "Failed to select outer parts",
); );
} }
} }
@@ -421,7 +653,7 @@ Optional: upload car green card file in the same step.
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-required-document", destination: "./files/claim-required-document",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -479,6 +711,8 @@ Optional: upload car green card file in the same step.
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFile() file?: Express.Multer.File, @UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
// Green-card photo is optional here — the helper no-ops on missing file.
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.selectOtherPartsV2( return await this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId, claimRequestId,
@@ -490,9 +724,7 @@ Optional: upload car green card file in the same step.
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error error instanceof Error ? error.message : "Failed to select other parts",
? error.message
: "Failed to select other parts",
); );
} }
} }
@@ -505,13 +737,13 @@ Optional: upload car green card file in the same step.
summary: "Get Capture Requirements (V2)", summary: "Get Capture Requirements (V2)",
description: ` description: `
**Get list of what needs to be captured:** **Get list of what needs to be captured:**
- Required documents (13 items) - Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
- Car angles (4 items: front, back, left, right) - Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). Returns status of each item (uploaded/captured or not).
**V2 order:** Complete angles and part photos first, then required documents. **V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -559,7 +791,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-documents", destination: "./files/claim-documents",
@@ -643,6 +875,7 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.uploadRequiredDocumentV2( return await this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId, claimRequestId,
@@ -666,7 +899,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-captures", destination: "./files/claim-captures",
@@ -689,7 +922,7 @@ Returns status of each item (uploaded/captured or not).
1. **angle**: Car angles (front, back, left, right) - 4 required 1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2 2. **part**: Damaged parts based on selectedParts from Step 2
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). **When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party. **Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
`, `,
@@ -713,7 +946,7 @@ Returns status of each item (uploaded/captured or not).
type: "string", type: "string",
example: "front", example: "front",
description: description:
'For angle: front/back/left/right. For part: hood/front_bumper/etc.', "For angle: front/back/left/right. For part: hood/front_bumper/etc.",
}, },
file: { file: {
type: "string", type: "string",
@@ -737,6 +970,7 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
): Promise<CapturePartV2ResponseDto> { ): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.capturePartV2( return await this.claimRequestManagementService.capturePartV2(
claimRequestId, claimRequestId,
@@ -759,12 +993,12 @@ Returns status of each item (uploaded/captured or not).
@Patch("request/reply/:claimRequestId/:partId/upload-factor") @Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Upload factor file for a priced part (V2)", summary: "Upload repair factor file for a factor-needed part (V2)",
description: description:
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " + "Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" +
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " + "**Requires:** `ClaimCaseStatus` **`OWNER_REPAIR_FACTOR_UPLOAD_PENDING`** or **`INSURER_REVIEW_MIXED_FACTORS_PENDING`** (or legacy **`WAITING_FOR_INSURER_APPROVAL`**), `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" +
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " + "**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" +
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.", "When every `factorNeeded` line has `factorLink`, the case moves to **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.",
}) })
@ApiParam({ name: "claimRequestId", description: "Claim case ID" }) @ApiParam({ name: "claimRequestId", description: "Claim case ID" })
@ApiParam({ name: "partId", description: "Part id from expert reply" }) @ApiParam({ name: "partId", description: "Part id from expert reply" })
@@ -785,7 +1019,7 @@ Returns status of each item (uploaded/captured or not).
callback(null, `-${unique}-${file.originalname}`); callback(null, `-${unique}-${file.originalname}`);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadFactorForPartV2( async uploadFactorForPartV2(
@@ -794,6 +1028,7 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
) { ) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.uploadClaimFactorV2( return await this.claimRequestManagementService.uploadClaimFactorV2(
claimRequestId, claimRequestId,
@@ -823,7 +1058,7 @@ Returns status of each item (uploaded/captured or not).
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-capture-videos/", destination: "./files/car-capture-videos/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -854,7 +1089,10 @@ Returns status of each item (uploaded/captured or not).
description: "Video uploaded successfully", description: "Video uploaded successfully",
type: VideoCaptureV2ResponseDto, type: VideoCaptureV2ResponseDto,
}) })
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" }) @ApiResponse({
status: 400,
description: "Wrong workflow step or missing file",
})
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Video already uploaded" }) @ApiResponse({ status: 409, description: "Video already uploaded" })
@@ -863,6 +1101,7 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile("file") file: Express.Multer.File, @UploadedFile("file") file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
): Promise<VideoCaptureV2ResponseDto> { ): Promise<VideoCaptureV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
try { try {
return await this.claimRequestManagementService.setVideoCaptureV2( return await this.claimRequestManagementService.setVideoCaptureV2(
claimRequestId, claimRequestId,
@@ -873,7 +1112,9 @@ Returns status of each item (uploaded/captured or not).
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload car capture video", error instanceof Error
? error.message
: "Failed to upload car capture video",
); );
} }
} }

View File

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

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
/** /**
* DTO for required document item * DTO for required document item
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'], enum: ['general', 'damaged_party', 'guilty_party'],
}) })
category: string; category: string;
@ApiPropertyOptional({
description:
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
example: true,
})
preferUploadDuringCapture?: boolean;
} }
/** /**
@@ -71,10 +79,17 @@ export class CarAngleItem {
*/ */
export class DamagedPartItem { export class DamagedPartItem {
@ApiProperty({ @ApiProperty({
description: 'Part key', description:
example: 'hood', 'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
example: 'backfender',
}) })
key: string; name: string;
@ApiPropertyOptional({
description: 'Vehicle side / region (left, right, front, back, top)',
example: 'left',
})
side?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in Farsi', description: 'Display label in Farsi',
@@ -82,6 +97,12 @@ export class DamagedPartItem {
}) })
label_fa: string; label_fa: string;
@ApiPropertyOptional({
description: 'Deprecated: same as `name` (kept for older clients)',
example: 'backfender',
})
key?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in English', description: 'Display label in English',
example: 'Hood', example: 'Hood',
@@ -93,6 +114,12 @@ export class DamagedPartItem {
example: false, example: false,
}) })
captured: boolean; captured: boolean;
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
@ApiPropertyOptional({ example: 12 })
@IsOptional()
@IsInt()
id?: number;
} }
/** /**
@@ -117,6 +144,20 @@ export class GetCaptureRequirementsV2ResponseDto {
}) })
currentStep: string; currentStep: string;
@ApiProperty({
description:
'Ordered capture phase during CAPTURE_PART_DAMAGES: parts → angles → capture_phase_documents',
example: 'angles',
enum: ['parts', 'angles', 'capture_phase_documents', 'complete'],
})
captureSequencePhase: string;
@ApiProperty({
description: 'Human-readable hint for what the user should do next in the capture step',
example: 'Capture all four car angles (front, back, left, right) next.',
})
captureSequenceHint: string;
@ApiProperty({ @ApiProperty({
description: 'List of required documents to upload', description: 'List of required documents to upload',
type: [RequiredDocumentItem], type: [RequiredDocumentItem],
@@ -159,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
anglesTotal: number; anglesTotal: number;
partsCaptured: number; partsCaptured: number;
partsTotal: number; partsTotal: number;
capturePhaseDocsRemaining: number;
postCaptureDocumentsRemaining: number;
}; };
} }

View File

@@ -1,4 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
export class ClaimDetailsOwnerNextActionV2Dto {
@ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' })
key: string;
@ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' })
method?: string;
@ApiPropertyOptional({
description: 'Path under API root',
example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign',
})
pathTemplate?: string;
@ApiProperty({ description: 'What this endpoint does for the user' })
description: string;
}
/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */
export class ClaimDetailsOwnerGuidanceV2Dto {
@ApiProperty({
description: 'Machine-readable phase',
enum: [
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPERT_RESEND',
'WAITING_DAMAGE_EXPERT',
'EXPERT_REVIEWING',
'USER_FLOW',
'UPLOAD_FACTORS',
'EXPERT_VALIDATING_FACTORS',
'SIGN_PRICED_LINES',
'INSURER_REVIEW_NEEDS_REVISION',
'FINAL_SIGN_OR_REJECT',
'INSURER_APPROVAL_FALLBACK',
],
})
phaseKey: string;
@ApiProperty({ description: 'Short headline for the current phase' })
headline: string;
@ApiPropertyOptional({ description: 'Longer UX copy' })
detail?: string;
@ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] })
nextActions: ClaimDetailsOwnerNextActionV2Dto[];
@ApiPropertyOptional({
description: 'Whether PUT objection is permitted (see server validation for exact rules)',
})
objectionAllowed?: boolean;
@ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' })
objectionHint?: string;
}
/** Active damage-expert resend request (owner must upload or acknowledge). */ /** Active damage-expert resend request (owner must upload or acknowledge). */
export class ExpertResendDetailsV2Dto { export class ExpertResendDetailsV2Dto {
@@ -8,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional({ type: [String] }) @ApiPropertyOptional({ type: [String] })
resendDocuments?: string[]; resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] }) @ApiPropertyOptional({
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>; description:
"Damaged parts the expert asked to re-capture (same shape as damagedParts: id, name, side, label_fa, catalogKey, captured, url).",
type: [Object],
})
resendCarParts?: Array<{
id?: number | null;
name?: string;
side?: string;
label_fa?: string;
label_en?: string;
catalogKey?: string;
key?: string;
captured?: boolean;
url?: string;
path?: string;
fileName?: string;
}>;
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' }) @ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
fulfilledAt?: Date; fulfilledAt?: Date;
@@ -25,7 +100,11 @@ export class ClaimDetailsV2ResponseDto {
@ApiProperty({ description: 'Request number' }) @ApiProperty({ description: 'Request number' })
requestNo: string; requestNo: string;
@ApiProperty({ description: 'Overall case status' }) @ApiProperty({
description:
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
})
status: string; status: string;
@ApiProperty({ description: 'Claim damage status' }) @ApiProperty({ description: 'Claim damage status' })
@@ -43,9 +122,13 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number' }) @ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string; blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Owner info' }) @ApiPropertyOptional({
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
})
owner?: { owner?: {
userId: string; userId: string;
clientId?: string;
userClientKey?: string;
fullName?: string; fullName?: string;
}; };
@@ -57,8 +140,11 @@ export class ClaimDetailsV2ResponseDto {
plate?: any; plate?: any;
}; };
@ApiPropertyOptional({ description: 'Selected outer damaged parts' }) @ApiPropertyOptional({
selectedParts?: string[]; description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[]; otherParts?: string[];
@@ -75,8 +161,36 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>; description:
'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)',
type: 'array',
items: {
type: 'object',
properties: {
index: { type: 'number' },
id: { type: 'number', nullable: true },
name: { type: 'string' },
side: { type: 'string' },
label_fa: { type: 'string' },
captured: { type: 'boolean' },
url: { type: 'string' },
path: { type: 'string' },
fileName: { type: 'string' },
},
},
})
damagedParts?: Array<{
index: number;
id?: number | null;
name: string;
side: string;
label_fa: string;
captured: boolean;
url?: string;
path?: string;
fileName?: string;
}>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
@@ -93,6 +207,13 @@ export class ClaimDetailsV2ResponseDto {
damageExpertReplyFinal?: unknown; damageExpertReplyFinal?: unknown;
}; };
@ApiPropertyOptional({
type: ClaimDetailsOwnerGuidanceV2Dto,
description:
'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.',
})
ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto;
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' }) @ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
userRating?: { userRating?: {
progressSpeed: number; progressSpeed: number;

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ClaimListItemV2Dto { export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' }) @ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
@@ -10,7 +10,11 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim request number', example: 'CL12345' }) @ApiProperty({ description: 'Claim request number', example: 'CL12345' })
requestNo: string; requestNo: string;
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' }) @ApiProperty({
description:
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
})
status: string; status: string;
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' }) @ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
@@ -30,6 +34,17 @@ export class GetMyClaimsV2ResponseDto {
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] }) @ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[]; list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count', example: 5 }) @ApiProperty({ description: 'Total count after search filter', example: 5 })
total: number; total: number;
@ApiPropertyOptional({
description: 'Current page when `page` or `limit` query params were sent',
})
page?: number;
@ApiPropertyOptional({ description: 'Page size when paginating' })
limit?: number;
@ApiPropertyOptional({ description: 'Total pages when paginating' })
totalPages?: number;
} }

View File

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

View File

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

View File

@@ -1,9 +1,18 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator'; import {
IsArray,
IsEnum,
IsNotEmpty,
ArrayMinSize,
ArrayUnique,
IsOptional,
IsInt,
} from "class-validator";
import { import {
ClaimVehicleTypeV2, ClaimVehicleTypeV2,
OuterPartSideV2, OuterPartSideV2,
} from "src/static/outer-car-parts-catalog"; } from "src/static/outer-car-parts-catalog";
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
/** /**
* Enum for valid outer car parts that can be damaged * Enum for valid outer car parts that can be damaged
@@ -11,27 +20,27 @@ import {
*/ */
export enum OuterCarPart { export enum OuterCarPart {
// Hood // Hood
HOOD = 'hood', HOOD = "hood",
// Doors // Doors
FRONT_RIGHT_DOOR = 'front_right_door', FRONT_RIGHT_DOOR = "front_right_door",
FRONT_LEFT_DOOR = 'front_left_door', FRONT_LEFT_DOOR = "front_left_door",
REAR_RIGHT_DOOR = 'rear_right_door', REAR_RIGHT_DOOR = "rear_right_door",
REAR_LEFT_DOOR = 'rear_left_door', REAR_LEFT_DOOR = "rear_left_door",
// Bumpers // Bumpers
FRONT_BUMPER = 'front_bumper', FRONT_BUMPER = "front_bumper",
REAR_BUMPER = 'rear_bumper', REAR_BUMPER = "rear_bumper",
// Fenders // Fenders
FRONT_RIGHT_FENDER = 'front_right_fender', FRONT_RIGHT_FENDER = "front_right_fender",
FRONT_LEFT_FENDER = 'front_left_fender', FRONT_LEFT_FENDER = "front_left_fender",
REAR_RIGHT_FENDER = 'rear_right_fender', REAR_RIGHT_FENDER = "rear_right_fender",
REAR_LEFT_FENDER = 'rear_left_fender', REAR_LEFT_FENDER = "rear_left_fender",
// Trunk & Roof // Trunk & Roof
TRUNK = 'trunk', TRUNK = "trunk",
ROOF = 'roof', ROOF = "roof",
} }
/** /**
@@ -40,38 +49,38 @@ export enum OuterCarPart {
*/ */
export class SelectOuterPartsV2Dto { export class SelectOuterPartsV2Dto {
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged outer car parts', description: "Array of selected damaged outer car parts",
example: ['hood', 'front_right_door', 'rear_bumper', 'roof'], example: ["hood", "front_right_door", "rear_bumper", "roof"],
enum: OuterCarPart, enum: OuterCarPart,
isArray: true, isArray: true,
minItems: 1, minItems: 1,
maxItems: 13, maxItems: 13,
}) })
@IsOptional() @IsOptional()
@IsArray({ message: 'selectedParts must be an array' }) @IsArray({ message: "selectedParts must be an array" })
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' }) @ArrayMinSize(1, { message: "At least one damaged part must be selected" })
@ArrayUnique({ message: 'Duplicate parts are not allowed' }) @ArrayUnique({ message: "Duplicate parts are not allowed" })
@IsEnum(OuterCarPart, { @IsEnum(OuterCarPart, {
each: true, each: true,
message: 'Invalid part name. Must be one of the valid outer car parts', message: "Invalid part name. Must be one of the valid outer car parts",
}) })
selectedParts?: OuterCarPart[]; selectedParts?: OuterCarPart[];
@ApiProperty({ @ApiProperty({
description: 'Selected outer part IDs from catalog', description: "Selected outer part IDs from catalog",
example: [9, 10, 4], example: [9, 10, 4],
type: [Number], type: [Number],
required: false, required: false,
}) })
@IsOptional() @IsOptional()
@IsArray({ message: 'selectedPartIds must be an array' }) @IsArray({ message: "selectedPartIds must be an array" })
@ArrayMinSize(1, { message: 'At least one part ID must be selected' }) @ArrayMinSize(1, { message: "At least one part ID must be selected" })
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' }) @ArrayUnique({ message: "Duplicate part IDs are not allowed" })
@IsInt({ each: true, message: 'Each selected part ID must be an integer' }) @IsInt({ each: true, message: "Each selected part ID must be an integer" })
selectedPartIds?: number[]; selectedPartIds?: number[];
@ApiProperty({ @ApiProperty({
description: 'Vehicle type for validating available outer parts', description: "Vehicle type for validating available outer parts",
enum: ClaimVehicleTypeV2, enum: ClaimVehicleTypeV2,
required: true, required: true,
}) })
@@ -85,47 +94,55 @@ export class SelectOuterPartsV2Dto {
*/ */
export class SelectOuterPartsV2ResponseDto { export class SelectOuterPartsV2ResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Claim request ID', description: "Claim request ID",
example: '507f1f77bcf86cd799439011', example: "507f1f77bcf86cd799439011",
}) })
claimRequestId: string; claimRequestId: string;
@ApiProperty({ @ApiProperty({
description: 'Public ID shared across blame and claim', description: "Public ID shared across blame and claim",
example: 'A14235', example: "A14235",
}) })
publicId: string; publicId: string;
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged parts', description:
example: ['hood', 'front_right_door', 'rear_bumper'], "Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
type: [DamageSelectedPartV2BodyDto],
}) })
selectedParts: string[]; selectedParts: DamageSelectedPartV2BodyDto[];
@ApiProperty({ @ApiProperty({
description: 'Selected part IDs', description: "Selected part IDs",
example: [9, 7, 11], example: [9, 7, 11],
type: [Number], type: [Number],
}) })
selectedPartIds: number[]; selectedPartIds: number[];
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: "Current workflow step",
example: 'SELECT_OUTER_PARTS', example: "SELECT_OUTER_PARTS",
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Next possible workflow step', description: "Next possible workflow step",
example: 'SELECT_OTHER_PARTS', example: "SELECT_OTHER_PARTS",
}) })
nextStep: string; nextStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', description: "Success message",
example: 'Outer parts selected successfully. Please proceed to select other parts.', example:
"Outer parts selected successfully. Please proceed to select other parts.",
}) })
message: string; message: string;
@ApiPropertyOptional({
description:
"Best-effort Fanavaran damage-case submit result. Selecting parts still succeeds when this contains a warning.",
})
fanavaranDamageCase?: unknown;
} }
export class SetClaimVehicleTypeV2Dto { export class SetClaimVehicleTypeV2Dto {
@@ -138,19 +155,54 @@ export class SetClaimVehicleTypeV2Dto {
carType: ClaimVehicleTypeV2; carType: ClaimVehicleTypeV2;
} }
/**
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
* controllers). It mirrors how items are persisted under
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
* can match catalog rows to stored selections without any field renaming:
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
* parentheses, and the original full catalog key (`left_backfender`) is
* exposed as `catalogKey`.
*/
export class OuterPartCatalogItemDto { export class OuterPartCatalogItemDto {
@ApiProperty() @ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
})
id: number; id: number;
@ApiProperty() @ApiProperty({
key: string; description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty() @ApiProperty({
titleFa: string; description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({ enum: OuterPartSideV2 }) @ApiProperty({
side: OuterPartSideV2; description:
"Display label in Farsi, with side disambiguator in parentheses when needed",
example: "چرخ عقب (چپ)",
})
label_fa: string;
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false }) @ApiProperty({
description: "Original full catalog key (matches stored `catalogKey`)",
example: "left_backWheel",
required: false,
})
catalogKey?: string;
@ApiProperty({
description: "Vehicle type this catalog row belongs to",
enum: ClaimVehicleTypeV2,
required: false,
example: "suv",
})
carType?: ClaimVehicleTypeV2; carType?: ClaimVehicleTypeV2;
} }

View File

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

View File

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

View File

@@ -1,47 +1,113 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
Validate,
ValidateNested,
} from "class-validator";
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
import { ObjectionPartHasContentConstraint } from "src/common/validators/objection-part-content.validator";
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
export class UserObjectionPartDto { export class UserObjectionPartDto {
@ApiProperty() @ApiProperty({
partId: string; example: 201,
description: "Numeric catalog part id of the priced line being disputed.",
})
@Validate(ObjectionPartHasContentConstraint)
@IsInt()
partId: number;
@ApiProperty({ required: false }) @ApiPropertyOptional({
description:
"Why the owner disagrees (min 3 chars when provided). Required unless partPrice or partSalary is sent.",
})
@IsOptional()
@IsString()
@MaxLength(2000)
reason?: string; reason?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({
description: "Owner-proposed part price (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partPrice?: string; partPrice?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({
description: "Owner-proposed salary / labor (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partSalary?: string; partSalary?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({ enum: TypeOfDamage })
@IsOptional()
@IsEnum(TypeOfDamage)
typeOfDamage?: TypeOfDamage; typeOfDamage?: TypeOfDamage;
@ApiProperty({ required: false }) @ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
carPartDamage?: string; carPartDamage?: string;
@ApiProperty({ required: false }) @ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
side?: string; side?: string;
} }
export class NewPartDto { export class NewPartDto {
@ApiProperty({ required: false, nullable: true }) @ApiPropertyOptional({
partId: string | null; nullable: true,
description: "Optional catalog id when the new part exists in the outer catalog.",
})
@IsOptional()
@IsInt()
partId?: number | null;
@ApiProperty() @ApiProperty({ example: "سپر جلو" })
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(200)
partName: string; partName: string;
@ApiProperty({ required: false }) @ApiPropertyOptional({ example: "front" })
@IsOptional()
@IsString()
@MaxLength(64)
side?: string; side?: string;
} }
export class UserObjectionDto { export class UserObjectionDto {
@ApiProperty({ type: [UserObjectionPartDto], required: false }) @ApiPropertyOptional({ type: [UserObjectionPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto) @Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[]; objectionParts?: UserObjectionPartDto[];
@ApiProperty({ type: [NewPartDto], required: false }) @ApiPropertyOptional({ type: [NewPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto) @Type(() => NewPartDto)
newParts?: NewPartDto[]; newParts?: NewPartDto[];
} }

View File

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

View File

@@ -9,15 +9,16 @@ import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userRepl
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimPartPricing { export class ClaimPartPricing {
@Prop({ type: String }) @Prop({ type: Number })
partId: string; partId: number;
/** /**
* Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2). * Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
* Must be Mixed — objects cannot cast to String. * Must be Mixed — objects cannot cast to String.
*/ */
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
carPartDamage?: string | { part?: string; side?: string }; carPartDamage?: unknown;
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@@ -76,6 +77,43 @@ export class ClaimUserComment {
export const ClaimUserCommentSchema = export const ClaimUserCommentSchema =
SchemaFactory.createForClass(ClaimUserComment); SchemaFactory.createForClass(ClaimUserComment);
/** Owner acceptance + signature after expert pricing (post-expert insurer `INSURER_REVIEW` — `INSURER_REVIEW_AWAITING_OWNER_SIGN` / `INSURER_REVIEW_MIXED_FACTORS_PENDING` / legacy `WAITING_FOR_INSURER_APPROVAL`). */
@Schema({ _id: false })
export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerInsurerApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
@Schema({ _id: false })
export class ClaimOwnerPricedPartsApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerPricedPartsApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimExpertReply { export class ClaimExpertReply {
@Prop({ type: String }) @Prop({ type: String })
@@ -103,8 +141,8 @@ export const ClaimExpertReplySchema =
/** One line item the user disputes on an expert-priced part */ /** One line item the user disputes on an expert-priced part */
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjectionPart { export class ClaimUserObjectionPart {
@Prop({ type: String, required: true }) @Prop({ type: Number, required: true })
partId: string; partId: number;
@Prop({ type: String }) @Prop({ type: String })
reason?: string; reason?: string;
@@ -129,8 +167,9 @@ export const ClaimUserObjectionPartSchema =
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjectionNewPart { export class ClaimUserObjectionNewPart {
@Prop({ type: String }) /** Catalog id when known; otherwise a generated string id for the new line. */
partId?: string; @Prop({ type: MongooseSchema.Types.Mixed })
partId?: number | string;
@Prop({ type: String, required: true }) @Prop({ type: String, required: true })
partName: string; partName: string;
@@ -141,6 +180,27 @@ export class ClaimUserObjectionNewPart {
export const ClaimUserObjectionNewPartSchema = export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart); SchemaFactory.createForClass(ClaimUserObjectionNewPart);
// ---------------------------------------------------------------------------
// Objection invoice (supporting document uploaded alongside the objection)
// ---------------------------------------------------------------------------
@Schema({ _id: false })
export class ClaimObjectionInvoice {
@Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() })
fileId: Types.ObjectId;
@Prop({ type: String, required: true })
path: string;
@Prop({ type: String, required: true })
fileName: string;
@Prop({ type: Date, default: () => new Date() })
uploadedAt: Date;
}
export const ClaimObjectionInvoiceSchema =
SchemaFactory.createForClass(ClaimObjectionInvoice);
/** /**
* Full user objection payload (matches v1 DTO: objectionParts + newParts). * Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}. * Stored on {@link ClaimEvaluation.objection}.
@@ -155,6 +215,10 @@ export class ClaimUserObjectionPayload {
@Prop({ type: Date, default: () => new Date() }) @Prop({ type: Date, default: () => new Date() })
submittedAt?: Date; submittedAt?: Date;
/** Optional supporting invoices uploaded at objection time. */
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
invoices?: ClaimObjectionInvoice[];
} }
export const ClaimUserObjectionPayloadSchema = export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload); SchemaFactory.createForClass(ClaimUserObjectionPayload);
@@ -177,6 +241,9 @@ export class ClaimResendRequest {
/** Damage expert profile when resend was requested (`damage-expert` collection). */ /** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema }) @Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot; expertProfileSnapshot?: ExpertProfileSnapshot;
@Prop({ type: Types.ObjectId })
requestedByExpertId?: Types.ObjectId;
} }
export const ClaimResendRequestSchema = export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest); SchemaFactory.createForClass(ClaimResendRequest);
@@ -205,6 +272,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 }) @Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number; guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
} }
export const ClaimFileRatingSchema = export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating); SchemaFactory.createForClass(ClaimFileRating);
@@ -225,6 +295,12 @@ export class ClaimPriceDrop {
@Prop({ type: Number }) @Prop({ type: Number })
sumOfSeverity?: number; sumOfSeverity?: number;
@Prop({ type: Number })
coefficientYear?: number;
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
partLines?: Array<Record<string, unknown>>;
} }
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop); export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
@@ -251,6 +327,12 @@ export class ClaimEvaluation {
@Prop({ type: ClaimFileRatingSchema }) @Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating; rating?: ClaimFileRating;
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
@Prop({ type: String }) @Prop({ type: String })
visitLocation?: string; visitLocation?: string;

View File

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

View File

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

View File

@@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type"; import {
HistoryEvent,
HistoryEventSchema,
} from "src/request-management/entities/schema/historyEvent.type";
import { import {
ClaimDamageSelection, ClaimDamageSelection,
ClaimDamageSelectionSchema, ClaimDamageSelectionSchema,
@@ -25,8 +28,15 @@ import {
ClaimCaseSnapshot, ClaimCaseSnapshot,
ClaimCaseSnapshotSchema, ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema"; } from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema"; import {
ClaimWorkflow,
ClaimWorkflowSchema,
} from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema"; import { UserClaimRating } from "./claim-request-management.schema";
import {
CaseInquiries,
CaseInquiriesSchema,
} from "src/common/schema/case-inquiries.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class RequiredDocumentRef { export class RequiredDocumentRef {
@@ -45,7 +55,66 @@ export class RequiredDocumentRef {
@Prop({ type: Date }) @Prop({ type: Date })
uploadedAt?: Date; uploadedAt?: Date;
} }
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(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({ @Schema({
collection: "claimCases", collection: "claimCases",
@@ -67,7 +136,12 @@ export class ClaimCase {
/** /**
* Overall case status (user flow + expert flow progression) * Overall case status (user flow + expert flow progression)
*/ */
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED }) @Prop({
required: true,
type: String,
enum: ClaimCaseStatus,
default: ClaimCaseStatus.CREATED,
})
status: ClaimCaseStatus; status: ClaimCaseStatus;
/** /**
@@ -92,6 +166,22 @@ export class ClaimCase {
@Prop({ type: String, index: true }) @Prop({ type: String, index: true })
blameRequestNo?: string; blameRequestNo?: string;
/**
* Set when this claim was created by a field expert on behalf of the damaged
* party (expert-initiated IN_PERSON flow). Used to scope the expert's panel
* access to only their own initiated files.
*/
@Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId;
/**
* The damaged party's userId, resolved from the blame at claim-creation time.
* Stored here so view/list access does not require an extra blame lookup.
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
*/
@Prop({ type: Types.ObjectId, index: true })
damagedPartyUserId?: Types.ObjectId;
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) }) @Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
workflow?: ClaimWorkflow; workflow?: ClaimWorkflow;
@@ -110,6 +200,15 @@ export class ClaimCase {
@Prop({ type: Number }) @Prop({ type: Number })
claimId?: number; claimId?: number;
@Prop({ type: Number })
dmgCaseId?: number;
@Prop({ type: Number })
expertiseId?: number;
@Prop({ type: FanavaranSyncStateSchema, default: () => ({}) })
fanavaranSync?: FanavaranSyncState;
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) }) @Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
damage?: ClaimDamageSelection; damage?: ClaimDamageSelection;
@@ -119,6 +218,9 @@ export class ClaimCase {
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) }) @Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
evaluation?: ClaimEvaluation; evaluation?: ClaimEvaluation;
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
inquiries?: CaseInquiries;
/** /**
* Optional “read-optimized” copy of fields from blame/request side. * Optional “read-optimized” copy of fields from blame/request side.
* Source of truth remains `blameRequestId`. * Source of truth remains `blameRequestId`.
@@ -149,6 +251,20 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true }) @Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId; createdByRegistrarId?: Types.ObjectId;
/**
* V5 split flow: when true, the claim must be approved by the FileMaker
* who created the file before fanavaran submission is allowed.
*/
@Prop({ type: Boolean, default: false })
requiresFileMakerApproval?: boolean;
/**
* V5 split flow: ObjectId of the FileMaker who must approve this claim.
* Set when the FileReviewer uploads the blame accident video in the V5 flow.
*/
@Prop({ type: Types.ObjectId, index: true })
fileMakerApprovalActorId?: Types.ObjectId;
/** /**
* Legacy fields kept optional to simplify progressive migration. * Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these. * If you choose to migrate later, we can remove these.
@@ -159,5 +275,4 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>; export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

@@ -1,5 +1,5 @@
import { Prop, Schema } from "@nestjs/mongoose"; import { Prop, Schema } from "@nestjs/mongoose";
import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "node:crypto";
@Schema({ versionKey: false, _id: false }) @Schema({ versionKey: false, _id: false })
export class ImageRequiredModel { export class ImageRequiredModel {
@@ -20,7 +20,7 @@ export class ImageRequiredModel {
constructor(claimFile: any[]) { constructor(claimFile: any[]) {
this.aroundTheCar.forEach((a) => { this.aroundTheCar.forEach((a) => {
Object.assign(a, { Object.assign(a, {
partId: uuidv4(), partId: randomUUID(),
imageId: null, imageId: null,
aiReport: {}, aiReport: {},
upload: false, upload: false,
@@ -28,7 +28,7 @@ export class ImageRequiredModel {
}); });
this.selectPartOfCar = claimFile.map((c, idx) => this.selectPartOfCar = claimFile.map((c, idx) =>
Object.assign(c, { Object.assign(c, {
partId: uuidv4(), partId: randomUUID(),
aiReport: {}, aiReport: {},
imageId: null, imageId: null,
upload: false, upload: false,

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,19 +2,21 @@ import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, Use
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "path"; import { extname } from "path";
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiConsumes, ApiExcludeController } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto"; import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto"; import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto"; import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto"; import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
@ApiTags("registrar-claim (v1)") @ApiExcludeController()
// @ApiTags("registrar-claim (v1)")
@Controller("registrar-claim") @Controller("registrar-claim")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@@ -23,7 +25,7 @@ export class RegistrarClaimV1Controller {
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {} constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" }) // @ApiParam({ name: "blameRequestId" })
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) { createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1( return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
blameRequestId, blameRequestId,
@@ -41,7 +43,7 @@ export class RegistrarClaimV1Controller {
} }
@Patch("select-outer-parts/:claimRequestId") @Patch("select-outer-parts/:claimRequestId")
@ApiBody({ type: SelectOuterPartsV2Dto }) // @ApiBody({ type: SelectOuterPartsV2Dto })
selectOuter( selectOuter(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto, @Body() body: SelectOuterPartsV2Dto,
@@ -56,7 +58,7 @@ export class RegistrarClaimV1Controller {
} }
@Patch("select-other-parts/:claimRequestId") @Patch("select-other-parts/:claimRequestId")
@ApiBody({ type: SelectOtherPartsV2Dto }) // @ApiBody({ type: SelectOtherPartsV2Dto })
selectOther( selectOther(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto, @Body() body: SelectOtherPartsV2Dto,
@@ -83,14 +85,14 @@ export class RegistrarClaimV1Controller {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-documents", destination: "./files/claim-documents",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
}), }),
}), }),
) )
@ApiOperation({ summary: "Registrar uploads required claim document" }) // @ApiOperation({ summary: "Registrar uploads required claim document" })
uploadDoc( uploadDoc(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto, @Body() body: UploadRequiredDocumentV2Dto,
@@ -107,10 +109,10 @@ export class RegistrarClaimV1Controller {
} }
@Post("capture-part/:claimRequestId") @Post("capture-part/:claimRequestId")
@ApiConsumes("multipart/form-data") // @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-captures", destination: "./files/claim-captures",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),

View File

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

View File

@@ -0,0 +1,71 @@
import {
Body,
Controller,
Get,
Patch,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
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 { ClientService } from "./client.service";
import {
ClientSettingsPanelResponseDto,
UpdateClientSettingsDto,
} from "./dto/client-settings.dto";
/**
* Insurer (company) panel: per-tenant upload limits and CAR_BODY accident window.
* Scoped to the JWT `clientKey` — tenants cannot edit other clients.
*/
@Controller("client-panel")
@ApiTags("insurer-client-panel")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.COMPANY)
export class ClientPanelController {
constructor(private readonly clientService: ClientService) {}
@Get("settings")
@ApiOperation({
summary: "Get tenant settings (media limits & CAR_BODY accident window)",
description:
"Returns configured overrides, effective values (with system defaults), and route upload ceilings for the authenticated insurer's client.",
})
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
async getSettings(
@CurrentUser() insurer: { clientKey?: string },
): Promise<ClientSettingsPanelResponseDto> {
if (!insurer?.clientKey) {
throw new UnauthorizedException("Insurer client key is missing.");
}
return this.clientService.getPanelSettings(insurer.clientKey);
}
@Patch("settings")
@ApiOperation({
summary: "Update tenant settings",
description:
"Partial update of `settings.carBodyAccidentMaxAgeDays` and/or `settings.media` (video, image, voice). " +
"Only fields sent in the body are changed. `maxBytes` cannot exceed the route ceiling returned by GET.",
})
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
async updateSettings(
@CurrentUser() insurer: { clientKey?: string },
@Body() body: UpdateClientSettingsDto,
): Promise<ClientSettingsPanelResponseDto> {
if (!insurer?.clientKey) {
throw new UnauthorizedException("Insurer client key is missing.");
}
return this.clientService.updatePanelSettings(insurer.clientKey, body);
}
}

View File

@@ -2,36 +2,84 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
Patch,
Post, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import {
import { GlobalGuard } from "src/auth/guards/global.guard"; ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
import { ClientDto } from "./dto/create-client.dto"; import { ClientDto } from "./dto/create-client.dto";
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
@Controller("client") @Controller("client")
@ApiTags("client-management") @ApiTags("client-management")
export class ClientController { export class ClientController {
constructor(private readonly clientService: ClientService) {} constructor(
private readonly clientService: ClientService,
private readonly systemSettingsService: SystemSettingsService,
) {}
@Post() @Post()
@UseGuards(GlobalGuard) @UseGuards(SuperAdminGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: "Create a new insurer client (super-admin only)" })
async addClient(@Body() client: ClientDto) { async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client); return await this.clientService.addClient(client);
} }
@Get() @Get()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard)
async getClient(@CurrentUser() user) { async getClient(@CurrentUser() user) {
return await this.clientService.getClients(); return await this.clientService.getClients();
} }
@Get("list") @Get("list")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClientList(@CurrentUser() user) { async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList(); return await this.clientService.getClientList();
} }
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({
summary: "Enable or disable live external inquiries (super-admin only)",
description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
})
@ApiBody({
type: SetExternalInquiriesLiveDto,
examples: {
enableLive: {
summary: "Enable live inquiries",
description: "Call real SandHub/Tejarat HTTP APIs.",
value: { enabled: true },
},
disableLive: {
summary: "Disable live inquiries (mock mode)",
description:
"Use mocked inquiry responses; flows continue without external connectivity.",
value: { enabled: false },
},
},
})
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
async setExternalInquiriesLive(@Body() body?: SetExternalInquiriesLiveDto) {
return this.systemSettingsService.updateSettings({
externalApis: { sandHubUseLiveApi: body?.enabled === true },
});
}
} }

View File

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

View File

@@ -1,32 +1,148 @@
import { BadGatewayException, GoneException, Injectable } from "@nestjs/common"; import {
BadGatewayException,
BadRequestException,
GoneException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { import {
ClientDto, ClientDto,
ClientDtoRs, ClientDtoRs,
ClientLists, ClientLists,
} from "src/client/dto/create-client.dto"; } from "src/client/dto/create-client.dto";
import {
type ClientSettingsPanelResponseDto,
UpdateClientSettingsDto,
} from "src/client/dto/client-settings.dto";
import type { ClientMediaLimits } from "./entities/schema/client.schema";
import { ClientDbService } from "./entities/db-service/client.db.service"; import { ClientDbService } from "./entities/db-service/client.db.service";
import {
ClientExternalInquiriesCatalogDto,
ClientExternalInquiriesListDto,
ClientExternalInquiriesViewDto,
toClientExternalInquiriesView,
UpdateClientExternalInquiriesDto,
} from "./dto/client-external-inquiries.dto";
import {
EXTERNAL_INQUIRY_TYPES,
mergeExternalInquiryFlags,
} from "src/common/types/external-inquiry.types";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
/**
* System-wide default applied when a client document has no
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
* gate is enforced even for older / unconfigured clients.
*/
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 5;
/**
* Media kinds we enforce upload-size bounds for. Each kind has its own
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
* per-client override under `settings.media.<kind>`.
*/
export type MediaKind =
| "video"
| "image"
| "voice"
| "chassisNumber"
| "carPlate";
export interface MediaLimits {
/** Inclusive lower bound. `0` allows any size from zero up. */
minBytes: number;
/** Inclusive upper bound. */
maxBytes: number;
}
/**
* System defaults applied when a client (or kind) has no explicit override.
*
* Important: each upload route also has a `multer.limits.fileSize` hard
* ceiling — those route ceilings are the absolute maximum the API can
* receive. Per-client `maxBytes` cannot legitimately go above its route's
* multer ceiling, so the defaults below are kept at-or-below those.
*/
export const DEFAULT_MEDIA_MIN_BYTES = 10; // 10 Byte
export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
video: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
image: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
voice: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
carPlate: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
chassisNumber: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
};
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
video: DEFAULT_MEDIA_MAX_BYTES,
image: DEFAULT_MEDIA_MAX_BYTES,
voice: DEFAULT_MEDIA_MAX_BYTES,
carPlate: DEFAULT_MEDIA_MAX_BYTES,
chassisNumber: DEFAULT_MEDIA_MAX_BYTES,
};
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365;
const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
@Injectable() @Injectable()
export class ClientService { export class ClientService {
constructor(private readonly clientDbService: ClientDbService) {} constructor(
private readonly clientDbService: ClientDbService,
private readonly externalInquirySettingsService: ExternalInquirySettingsService,
private readonly systemSettingsService: SystemSettingsService,
) {}
async addClient(client: ClientDto): Promise<ClientDtoRs> { async addClient(client: ClientDto): Promise<ClientDtoRs> {
try { try {
const smsApiKey = client.property?.smsApiKey?.trim();
const newClient = await this.clientDbService.create({ const newClient = await this.clientDbService.create({
clientCode: client.clientCode, clientCode: client.clientCode,
clientName: { clientName: {
persian: client.clientName.persian, persian:
english: client.clientName.english || null, typeof client.clientName === "string"
? client.clientName
: client.clientName?.persian,
english:
typeof client.clientName === "string"
? null
: (client.clientName?.english ?? null),
}, },
property: {
smsApiKey: client.property.smsApiKey || null, ...(smsApiKey ? { property: { smsApiKey } } : {}),
},
useExpertMode: client.useExpertMode || null, useExpertMode: client.useExpertMode ?? null,
}); });
if (newClient) return new ClientDtoRs(newClient);
else throw new GoneException("database not connected"); if (!newClient) {
throw new GoneException("database not connected");
}
return new ClientDtoRs(newClient);
} catch (er) { } catch (er) {
throw new BadGatewayException(er.errors); console.error("ADD CLIENT ERROR:", er);
throw er;
} }
} }
@@ -42,6 +158,38 @@ export class ClientService {
return await this.clientDbService.find({ clientCode: companyCode }); return await this.clientDbService.find({ clientCode: companyCode });
} }
/**
* Resolve a client by insurer company code from an external inquiry.
* Creates the client when missing so inquiry flows don't fail on unknown codes.
*/
async findOrCreateClientByCompanyCode(
companyCode: number | string | null | undefined,
companyName: string | null | undefined,
) {
const name = typeof companyName === "string" ? companyName.trim() : "";
const code = Number(companyCode);
if (!name || !Number.isFinite(code)) {
return null;
}
let client = await this.clientDbService.find({ clientCode: code });
if (client) return client;
try {
await this.addClient({
clientName: { persian: name, english: null },
clientCode: code,
useExpertMode: "legal",
});
} catch (err) {
client = await this.clientDbService.find({ clientCode: code });
if (client) return client;
throw err;
}
return this.clientDbService.find({ clientCode: code });
}
async getClients(): Promise<ClientDtoRs[]> { async getClients(): Promise<ClientDtoRs[]> {
const clients = await this.clientDbService.findAll(); const clients = await this.clientDbService.findAll();
const show = clients.map((c) => new ClientDtoRs(c)); const show = clients.map((c) => new ClientDtoRs(c));
@@ -53,4 +201,282 @@ export class ClientService {
const list = client.map((element) => new ClientLists(element)); const list = client.map((element) => new ClientLists(element));
return list; return list;
} }
/**
* Resolve the per-client byte bounds for a media kind. Missing bounds fall
* back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it
* doesn't resolve to a client document) the defaults are returned.
*
* The returned object is always fully populated — callers can compare
* directly against `file.size`.
*/
async getMediaLimits(
clientId: string | Types.ObjectId | null | undefined,
kind: MediaKind,
): Promise<MediaLimits> {
const defaults = DEFAULT_MEDIA_LIMITS[kind];
if (!clientId) return { ...defaults };
const idString = String(clientId);
if (!Types.ObjectId.isValid(idString)) return { ...defaults };
const client = await this.clientDbService.findOne({
_id: new Types.ObjectId(idString),
});
const override = client?.settings?.media?.[kind];
const minBytes =
typeof override?.minBytes === "number" &&
Number.isFinite(override.minBytes) &&
override.minBytes >= 0
? override.minBytes
: defaults.minBytes;
const maxBytes =
typeof override?.maxBytes === "number" &&
Number.isFinite(override.maxBytes) &&
override.maxBytes > 0
? override.maxBytes
: defaults.maxBytes;
return { minBytes, maxBytes };
}
/**
* Resolve the per-client CAR_BODY accident-age window (in days).
*
* Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when:
* - no client id is supplied,
* - the id is malformed,
* - the client document is missing,
* - the client has no `settings.carBodyAccidentMaxAgeDays` configured,
* - or the configured value is not a positive finite number.
*/
async getCarBodyAccidentMaxAgeDays(
clientId?: string | Types.ObjectId | null,
): Promise<number> {
if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
const idString = String(clientId);
if (!Types.ObjectId.isValid(idString)) {
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
}
const client = await this.clientDbService.findOne({
_id: new Types.ObjectId(idString),
});
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
) {
return configured;
}
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
}
private resolveClientObjectId(
clientKey: string | Types.ObjectId,
): Types.ObjectId {
const raw = String(clientKey ?? "").trim();
if (!Types.ObjectId.isValid(raw)) {
throw new BadRequestException("Invalid client key");
}
return new Types.ObjectId(raw);
}
private async loadClientOrThrow(clientKey: string | Types.ObjectId) {
const id = this.resolveClientObjectId(clientKey);
const client = await this.clientDbService.findOne({ _id: id });
if (!client) {
throw new NotFoundException("Client not found");
}
return client;
}
private configuredMediaLimits(
stored: ClientMediaLimits | undefined,
): { minBytes?: number; maxBytes?: number } | null {
if (!stored) return null;
const hasMin = typeof stored.minBytes === "number";
const hasMax = typeof stored.maxBytes === "number";
if (!hasMin && !hasMax) return null;
return {
...(hasMin ? { minBytes: stored.minBytes } : {}),
...(hasMax ? { maxBytes: stored.maxBytes } : {}),
};
}
private validateMediaLimitsPatch(
kind: MediaKind,
patch: { minBytes?: number; maxBytes?: number },
): { minBytes?: number; maxBytes?: number } {
const defaults = DEFAULT_MEDIA_LIMITS[kind];
const routeMax = MEDIA_ROUTE_MAX_BYTES[kind];
const minBytes =
patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes;
const maxBytes =
patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes;
if (maxBytes > routeMax) {
throw new BadRequestException(
`${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`,
);
}
if (minBytes > maxBytes) {
throw new BadRequestException(
`${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`,
);
}
const out: { minBytes?: number; maxBytes?: number } = {};
if (patch.minBytes !== undefined) out.minBytes = patch.minBytes;
if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes;
return out;
}
async getPanelSettings(
clientKey: string | Types.ObjectId,
): Promise<ClientSettingsPanelResponseDto> {
const client = await this.loadClientOrThrow(clientKey);
const clientId = client._id.toString();
const configuredDays = client.settings?.carBodyAccidentMaxAgeDays;
const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id);
const media = {} as ClientSettingsPanelResponseDto["media"];
for (const kind of MEDIA_KINDS) {
const effective = await this.getMediaLimits(client._id, kind);
media[kind] = {
configured: this.configuredMediaLimits(client.settings?.media?.[kind]),
effective,
systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] },
routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind],
};
}
return {
clientId,
carBodyAccidentMaxAgeDays: {
configured:
typeof configuredDays === "number" && Number.isFinite(configuredDays)
? configuredDays
: null,
effective: effectiveDays,
systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS,
minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN,
maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX,
},
media,
};
}
async updatePanelSettings(
clientKey: string | Types.ObjectId,
body: UpdateClientSettingsDto,
): Promise<ClientSettingsPanelResponseDto> {
const client = await this.loadClientOrThrow(clientKey);
const $set: Record<string, unknown> = {};
if (body.carBodyAccidentMaxAgeDays !== undefined) {
$set["settings.carBodyAccidentMaxAgeDays"] =
body.carBodyAccidentMaxAgeDays;
}
if (body.media) {
for (const kind of MEDIA_KINDS) {
const patch = body.media[kind];
if (!patch) continue;
const validated = this.validateMediaLimitsPatch(kind, patch);
if (validated.minBytes !== undefined) {
$set[`settings.media.${kind}.minBytes`] = validated.minBytes;
}
if (validated.maxBytes !== undefined) {
$set[`settings.media.${kind}.maxBytes`] = validated.maxBytes;
}
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No settings fields to update.");
}
const updated = await this.clientDbService.findByIdAndUpdate(
client._id.toString(),
{ $set },
);
if (!updated) {
throw new NotFoundException("Client not found");
}
return this.getPanelSettings(client._id);
}
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
return {
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
perClientSettingPath: "/client/{clientId}/external-inquiries",
};
}
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
const global = await this.systemSettingsService.isSandHubLiveEnabled();
const clients = await this.clientDbService.findAll();
return {
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
};
}
async getExternalInquirySettings(
clientKey: string | Types.ObjectId,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const global = await this.systemSettingsService.isSandHubLiveEnabled();
return toClientExternalInquiriesView(client, global);
}
async replaceExternalInquirySettings(
clientKey: string | Types.ObjectId,
body: UpdateClientExternalInquiriesDto,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
const $set: Record<string, unknown> = {};
for (const key of EXTERNAL_INQUIRY_TYPES) {
$set[`settings.externalInquiries.${key}`] = flags[key];
}
const updated = await this.clientDbService.findByIdAndUpdate(
client._id.toString(),
{ $set },
);
if (!updated) throw new NotFoundException("Client not found");
this.externalInquirySettingsService.invalidateClientCache(
client._id.toString(),
);
return this.getExternalInquirySettings(client._id);
}
async patchExternalInquirySettings(
clientKey: string | Types.ObjectId,
body: UpdateClientExternalInquiriesDto,
): Promise<ClientExternalInquiriesViewDto> {
const client = await this.loadClientOrThrow(clientKey);
const $set: Record<string, unknown> = {};
for (const key of EXTERNAL_INQUIRY_TYPES) {
if (body[key] !== undefined) {
$set[`settings.externalInquiries.${key}`] = body[key];
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No external inquiry flags to update.");
}
const updated = await this.clientDbService.findByIdAndUpdate(
client._id.toString(),
{ $set },
);
if (!updated) throw new NotFoundException("Client not found");
this.externalInquirySettingsService.invalidateClientCache(
client._id.toString(),
);
return this.getExternalInquirySettings(client._id);
}
} }

View File

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

View File

@@ -0,0 +1,131 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsInt,
IsOptional,
Max,
Min,
ValidateNested,
} from "class-validator";
import type { MediaKind } from "../client.service";
export class ClientMediaLimitsDto {
@ApiPropertyOptional({
description: "Minimum upload size in bytes (inclusive). Omit to keep default.",
example: 5120,
})
@IsOptional()
@IsInt()
@Min(0)
minBytes?: number;
@ApiPropertyOptional({
description: "Maximum upload size in bytes (inclusive). Cannot exceed route ceiling.",
example: 8388608,
})
@IsOptional()
@IsInt()
@Min(1)
maxBytes?: number;
}
export class ClientMediaSettingsDto {
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
@IsOptional()
@ValidateNested()
@Type(() => ClientMediaLimitsDto)
video?: ClientMediaLimitsDto;
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
@IsOptional()
@ValidateNested()
@Type(() => ClientMediaLimitsDto)
image?: ClientMediaLimitsDto;
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
@IsOptional()
@ValidateNested()
@Type(() => ClientMediaLimitsDto)
voice?: ClientMediaLimitsDto;
}
export class UpdateClientSettingsDto {
@ApiPropertyOptional({
description:
"Max days between CAR_BODY accident date and submission. Omit to leave unchanged.",
example: 7,
minimum: 1,
maximum: 365,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(365)
carBodyAccidentMaxAgeDays?: number;
@ApiPropertyOptional({ type: ClientMediaSettingsDto })
@IsOptional()
@ValidateNested()
@Type(() => ClientMediaSettingsDto)
media?: ClientMediaSettingsDto;
}
export class MediaLimitsResolvedDto {
@ApiProperty({ example: 5120 })
minBytes: number;
@ApiProperty({ example: 8388608 })
maxBytes: number;
}
export class MediaKindSettingsViewDto {
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
configured: { minBytes?: number; maxBytes?: number } | null;
@ApiProperty({ type: MediaLimitsResolvedDto })
effective: MediaLimitsResolvedDto;
@ApiProperty({ type: MediaLimitsResolvedDto })
systemDefault: MediaLimitsResolvedDto;
@ApiProperty({
description: "Hard multer ceiling for this kind on upload routes (bytes)",
example: 10485760,
})
routeMaxBytes: number;
}
export class CarBodyAccidentWindowViewDto {
@ApiPropertyOptional({ example: 14, nullable: true })
configured: number | null;
@ApiProperty({ example: 14 })
effective: number;
@ApiProperty({ example: 7 })
systemDefault: number;
@ApiProperty({ example: 1 })
minDays: number;
@ApiProperty({ example: 365 })
maxDays: number;
}
export class ClientSettingsPanelResponseDto {
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
clientId: string;
@ApiProperty({ type: CarBodyAccidentWindowViewDto })
carBodyAccidentMaxAgeDays: CarBodyAccidentWindowViewDto;
@ApiProperty({
description: "Per-kind upload bounds (video / image / voice)",
example: {
video: {},
image: {},
voice: {},
},
})
media: Record<MediaKind, MediaKindSettingsViewDto>;
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
export class SetExternalInquiriesLiveDto {
@ApiProperty({
description:
"When true, SandHub/Tejarat live HTTP inquiries run. When false, mocked inquiry data is used.",
example: true,
})
enabled: boolean;
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,93 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
export type ClientDocument = ClientModel & Document; export type ClientDocument = ClientModel & Document;
/**
* Per-media (video/image/voice) byte bounds. Either bound is optional so a
* tenant can configure only one side (e.g. raise `maxBytes` without setting
* a floor). When a bound is missing the system-wide default is used (see
* {@link ClientService.getMediaLimits} for the defaults table).
*/
export class ClientMediaLimits {
@Prop({ type: Number, required: false })
minBytes?: number;
@Prop({ type: Number, required: false })
maxBytes?: number;
}
/**
* Bag of per-media-kind limits. Each kind is optional and any unset values
* fall back to the system default (see `ClientService.getMediaLimits`).
*
* Note: the system also keeps an absolute multer ceiling per upload route
* (e.g. 50MB for car-capture videos, 20MB for blame videos, 10MB for
* voice/signature/image). A client-configured `maxBytes` cannot exceed the
* route's multer ceiling because multer rejects oversize requests before
* the request reaches the policy check.
*/
export class ClientMediaSettings {
@Prop({ type: ClientMediaLimits, required: false })
video?: ClientMediaLimits;
@Prop({ type: ClientMediaLimits, required: false })
image?: ClientMediaLimits;
@Prop({ type: ClientMediaLimits, required: false })
voice?: ClientMediaLimits;
}
/**
* Per-insurer toggles for outbound inquiry HTTP. Each flag is AND-ed with the
* global `system_settings.externalApis.sandHubUseLiveApi` master switch.
*/
export class ClientExternalInquirySettings implements Partial<ExternalInquiryFlags> {
@Prop({ type: Boolean, required: false, default: false })
thirdPartyPlate?: boolean;
@Prop({ type: Boolean, required: false, default: false })
carBodyPlate?: boolean;
@Prop({ type: Boolean, required: false, default: false })
personalIdentity?: boolean;
@Prop({ type: Boolean, required: false, default: false })
sheba?: boolean;
@Prop({ type: Boolean, required: false, default: false })
drivingLicense?: boolean;
@Prop({ type: Boolean, required: false, default: false })
carOwnership?: boolean;
}
/**
* Per-tenant tunables. Add new policy fields here; consumers read them via
* `ClientService` with documented defaults so older client documents keep
* working when a field is missing.
*/
export class ClientSettings {
/**
* Max number of days between the accident and the moment a CAR_BODY blame
* file is allowed to be submitted. When unset, consumers fall back to the
* system default (see {@link ClientService.getCarBodyAccidentMaxAgeDays}).
*/
@Prop({ type: Number, required: false })
carBodyAccidentMaxAgeDays?: number;
/**
* Per-kind media upload bounds (V2 upload endpoints). Unset kinds /
* bounds fall back to the defaults in `ClientService.getMediaLimits`.
*/
@Prop({ type: ClientMediaSettings, required: false })
media?: ClientMediaSettings;
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
@Prop({ type: ClientExternalInquirySettings, required: false })
externalInquiries?: ClientExternalInquirySettings;
}
@Schema({ collection: "clients", versionKey: false }) @Schema({ collection: "clients", versionKey: false })
export class ClientModel { export class ClientModel {
@Prop({ required: true, unique: true, type: Object }) @Prop({ required: true, unique: true, type: Object })
@@ -10,9 +96,9 @@ export class ClientModel {
english: string; english: string;
}; };
@Prop({ required: false, unique: true, type: Object }) @Prop({ required: false, type: Object })
property: { property?: {
smsApiKey: string; smsApiKey?: string;
}; };
@Prop({ required: true, unique: false }) @Prop({ required: true, unique: false })
@@ -20,6 +106,9 @@ export class ClientModel {
@Prop({ required: true, unique: false }) @Prop({ required: true, unique: false })
clientCode: number; clientCode: number;
@Prop({ type: ClientSettings, required: false, default: {} })
settings?: ClientSettings;
} }
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel); export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);

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