Compare commits

..

437 Commits

Author SHA1 Message Date
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
SepehrYahyaee
e90f8dc33c YARA-833 2026-04-27 17:03:29 +03:30
4c4b1a1db7 Merge pull request 'Fixed legacy requestedCounts methods and statistics + claimLink address' (#42) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#42
2026-04-27 15:30:21 +03:30
SepehrYahyaee
c2f996cc28 Fixed legacy requestedCounts methods and statistics + claimLink address 2026-04-27 15:29:44 +03:30
362e02ddc4 Merge pull request 'Fix locks' (#41) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#41
2026-04-26 16:09:17 +03:30
SepehrYahyaee
4b0e8a3547 FIX LOCKS 2026-04-26 16:08:14 +03:30
SepehrYahyaee
885678df7d Fixed lock mechanism, and some resendItem types 2026-04-26 15:58:21 +03:30
8dffc46357 Merge pull request 'YARA-725, YARA-830, YARA-832' (#40) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#40
2026-04-26 11:44:54 +03:30
SepehrYahyaee
b5b3b722c6 YARA-725 2026-04-26 11:43:17 +03:30
SepehrYahyaee
4f8cb43883 YARA-832 2026-04-26 09:31:50 +03:30
SepehrYahyaee
6c2d178686 YARA-830 2026-04-26 09:16:58 +03:30
9854a58282 Merge pull request 'Fix' (#39) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#39
2026-04-25 17:38:23 +03:30
SepehrYahyaee
7184142137 Fix 2026-04-25 17:38:01 +03:30
d45975b6a7 Merge pull request 'YARA-837, YARA-836' (#38) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#38
2026-04-25 17:12:59 +03:30
SepehrYahyaee
05c5b70b4d YARA-837 2026-04-25 17:10:07 +03:30
SepehrYahyaee
5d2227b00b YARA-836 + 2 more sms 2026-04-25 16:53:56 +03:30
eef305c42e Merge pull request 'Added required documents for blame, added 2 sms' (#37) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#37
2026-04-25 15:15:11 +03:30
SepehrYahyaee
9c87927e6c Added required documents for blame, added 2 sms 2026-04-25 15:14:44 +03:30
5618d120e3 Merge pull request 'YARA-834' (#36) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#36
2026-04-22 15:03:28 +03:30
SepehrYahyaee
d9b1537ee4 Centralized SMS services YARA-834 2026-04-22 15:02:08 +03:30
f01882dc3a Merge pull request 'Added SMS for Link flow of FIELD-EXPERT: YARA-802' (#35) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#35
2026-04-22 11:14:08 +03:30
SepehrYahyaee
8284aba825 Added SMS for Link flow of FIELD-EXPERT: YARA-802 2026-04-22 11:13:47 +03:30
3c15901ecc Merge pull request 'Fixing createdAtFa' (#34) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#34
2026-04-22 10:35:39 +03:30
SepehrYahyaee
1e1edf9136 Fixing createdAtFa 2026-04-22 10:35:00 +03:30
daad9539ea Merge pull request 'YARA-821' (#33) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#33
2026-04-22 10:30:05 +03:30
SepehrYahyaee
194b71cdb2 YARA-821 2026-04-22 10:29:01 +03:30
SepehrYahyaee
04ba46ed2a Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-04-22 09:11:42 +03:30
c6f7edabd2 Merge pull request 'daghi problem fixed , 2 apis for reporting counts added' (#32) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#32
2026-04-21 16:44:28 +03:30
75b7e5ecad daghi problem fixed , 2 apis for reporting counts added 2026-04-21 16:43:15 +03:30
a3156881b8 Merge pull request 'daghi + expert and damage expert name in decision +' (#31) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#31
2026-04-20 18:20:34 +03:30
41c44dcf23 daghi + expert and damage expert name in decision +
signature required added
2026-04-20 18:19:25 +03:30
SepehrYahyaee
95bfc095ce Fixed some bugs on FaTimestamps 2026-04-20 12:11:12 +03:30
b63c2155bc Merge pull request 'blame status agreement fixed' (#30) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#30
2026-04-20 11:22:04 +03:30
9463142ecf blame status agreement fixed 2026-04-20 11:19:49 +03:30
a71c4ea980 Merge pull request 'YARA-814' (#29) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#29
2026-04-20 10:49:28 +03:30
SepehrYahyaee
b826a133a4 YARA-814 2026-04-20 10:49:06 +03:30
b4501c503e Merge pull request 'main' (#28) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#28
2026-04-20 10:02:28 +03:30
SepehrYahyaee
a0d337aff0 Merge remote-tracking branch 'origin/main' 2026-04-20 10:00:02 +03:30
28e2de459a Merge pull request 'main' (#27) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#27
2026-04-20 09:53:53 +03:30
af1a07743e merge upstream 2026-04-19 17:23:47 +03:30
fca88bc151 update the resend request and user side 2026-04-19 17:23:14 +03:30
SepehrYahyaee
e264695db3 YARA-803 2026-04-19 13:08:47 +03:30
277f2d4d66 Merge pull request 'Fixed lock mechanism for damage-expert' (#26) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#26
2026-04-19 12:32:22 +03:30
SepehrYahyaee
514018beb1 Fixed lock mechanism for damage-expert 2026-04-19 12:32:03 +03:30
374b15933d Merge pull request 'add blame in claim details plus auto expert decision bug fixed' (#25) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#25
2026-04-19 11:59:33 +03:30
08a4d754c1 add blame in claim details plus auto expert decision bug fixed 2026-04-19 11:57:18 +03:30
c5b2d7b520 Merge pull request 'Fixed steps' (#24) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#24
2026-04-19 10:42:05 +03:30
SepehrYahyaee
5a84080bf1 Fixed steps 2026-04-19 10:41:51 +03:30
68b3b2000b Merge pull request 'Fixed statuses and switched some' (#23) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#23
2026-04-18 17:42:27 +03:30
SepehrYahyaee
028ca899fe resolved conflicts and maintained new features 2026-04-18 17:41:45 +03:30
SepehrYahyaee
0086b8db4d fixed many bugs and step issues 2026-04-18 17:04:19 +03:30
SepehrYahyaee
7f5b64f2a6 Fixed DTO 2026-04-18 14:02:50 +03:30
2898897dd9 Merge pull request 'lookups api is offline' (#22) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#22
2026-04-18 13:29:11 +03:30
e8a3873851 lookups api is offline 2026-04-18 13:26:37 +03:30
131df63a3f Merge pull request 'main' (#21) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#21
2026-04-18 12:35:41 +03:30
b9eb0ab5bd Merge remote-tracking branch 'upstream/main' | Merge upstream into main and reapply local changes 2026-04-18 12:30:32 +03:30
4bdb9fd469 update some important issues 2026-04-18 10:49:22 +03:30
38f700c3d7 Merge pull request 'Fixed bugs of car-body steps' (#19) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#19
2026-04-18 10:10:58 +03:30
SepehrYahyaee
1a1d55bc2e Fixed bugs of car-body steps 2026-04-18 10:10:19 +03:30
a2f1d5ea7f Merge pull request 'YARA-725' (#18) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#18
2026-04-15 12:32:53 +03:30
SepehrYahyaee
9a65071276 YARA-725 2026-04-15 12:32:25 +03:30
12d6fa4d73 Merge pull request 'YARA-784, YARA-789, YARA-791' (#17) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#17
2026-04-15 11:58:36 +03:30
SepehrYahyaee
640b240ada YARA-791 2026-04-15 11:57:09 +03:30
SepehrYahyaee
f8fbbb7ac6 YARA-784 2026-04-15 11:43:19 +03:30
SepehrYahyaee
0e2789c209 YARA-789 2026-04-15 10:16:18 +03:30
7661862564 Merge pull request 'Fixed reports+insurer + fixed field_expert not accessing claim APIs temporarily' (#16) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#16
2026-04-13 14:10:39 +03:30
SepehrYahyaee
a2143fe1c5 Fixed field_expert not accessing claim apis 2026-04-13 14:09:29 +03:30
Soheil Hajizadeh
494e3d93ab update the expert claim dto 2026-04-11 09:46:54 +03:30
9afb079786 Rework the reports and insurer part 2026-04-11 00:12:10 +03:30
25958bb966 merge upstream 2026-04-08 16:09:14 +03:30
Soheil Hajizadeh
1d51370b3e vehicle data added to get single request by expert , also submit in person 2026-04-08 16:08:31 +03:30
e44721b7be Merge pull request 'YARA-743, YARA-763, YARA-764' (#15) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#15
2026-04-06 15:00:02 +03:30
7ef057dc1f YARA-764 2026-04-06 14:33:33 +03:30
1b68a81204 YARA-763 2026-04-06 14:15:31 +03:30
f77da50d1f YARA-743 2026-04-06 14:01:45 +03:30
79c2982e19 Merge pull request 'Fixed log for error' (#14) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#14
2026-04-05 16:45:12 +03:30
fb4d166c31 Fixed log for error 2026-04-05 16:44:14 +03:30
9084f8fafb Merge pull request 'update the user side from the list of the user requests' (#13) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#13
2026-04-04 14:03:18 +03:30
b216a363fb merge upstream 2026-03-29 12:22:53 +03:30
Soheil Hajizadeh
b2391751f0 update the user side for the requests list 2026-03-29 12:21:01 +03:30
df6d2bd04d Merge pull request 'Mocked all external API calls' (#12) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#12
2026-03-29 11:48:37 +03:30
f277d87dfa Mocked all external API calls 2026-03-29 11:46:56 +03:30
672ec25438 Merge pull request 'Fixed signature error for car-body' (#11) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#11
2026-03-29 10:43:33 +03:30
aa871c86f6 Fixed signature error for car-body 2026-03-29 10:41:38 +03:30
553a34054c Merge pull request 'Added registrar + fixed sign bug in car-body flow' (#10) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#10
2026-03-26 16:37:33 +03:30
8af152abc0 Added registrar + fixed sign bug in car-body flow 2026-03-26 16:35:02 +03:30
0446c83b36 Merge pull request 'update the blame service' (#9) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#9
2026-03-18 15:53:40 +03:30
255cfd3eb3 Merge pull request 'Disabled Inquiry' (#8) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#8
2026-03-17 18:55:18 +03:30
b1be5b1e09 Disabled Inquiry 2026-03-17 18:53:47 +03:30
7d3565ec51 Merge pull request 'Added Expert initiated flow (field expert) + deactivated inquiry' (#7) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#7
2026-03-16 15:45:49 +03:30
fc599acf4a Added Field Expert flows, and deactivated inquiry 2026-03-16 15:39:39 +03:30
SepehrYahyaee
b40270f058 Added refactored version of car-body 2026-03-14 13:36:05 +03:30
Soheil Hajizadeh
8d6fa3daac update the blame service 2026-03-10 01:06:30 +03:30
64dfd1ca8a Merge pull request 'historical refactored yara724 merge pull request' (#6) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#6
2026-02-24 12:24:56 +03:30
665ed9e70f Merge pull request 'blame and claim refactored' (#1) from integrate-my-work into main
Reviewed-on: #1
2026-02-24 12:21:43 +03:30
345 changed files with 54982 additions and 18117 deletions

78
.env.example Normal file
View File

@@ -0,0 +1,78 @@
# ---------------------------------------------
# 🔧 Application Environment
# ---------------------------------------------
NODE_ENV =
PORT =
CLIENT_ID =
CLIENT_NAME =
# ---------------------------------------------
# 🌐 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 =
# ---------------------------------------------
# 🧩 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 =
# ---------------------------------------------
# ⚙️ 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 =

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
Current JWT payload:
```json
{
"username": "saman_insurer@gmail.com",
"sub": "6a144979799f3c63aa63f67c",
"fullName": "بیمه گر سامان",
"role": "company",
"userType": "UserType",
"clientKey": "67f0fd0e53868dc1ff8a2738",
"iat": 1781339791,
"exp": 1781343391
}
```

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

@@ -0,0 +1,728 @@
# 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"
```
### 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",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "../assets/fonts/**/*",
"outDir": "dist",
"watchAssets": true
}
]
}
}

17269
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@@ -11,9 +11,17 @@ export enum CaseStatus {
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
/**
* FileMaker has collected all signatures; the file is sealed and waiting
* for a FileReviewer to complete it (accident fields → capture → video).
*/
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED"
}
AUTO_CLOSED = "AUTO_CLOSED",
STOPPED = "STOPPED"
}

View File

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

View File

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

View File

@@ -11,11 +11,38 @@ export enum ClaimCaseStatus {
// User flow - damage capture
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
// Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
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",
/** 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",
// Final states
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",

View File

@@ -7,19 +7,27 @@ export enum ClaimWorkflowStep {
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
// User: Documentation phase
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User: Damage capture phase (per part)
// User: Damage capture phase (angles + per-part photos) — before document upload in v2
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
// User: Documentation phase — after captures in v2
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User submission complete
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
// Expert: Damage assessment
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
/** After user objection: damage experts last priced reply (stored in evaluation.damageExpertReplyFinal) */
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
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_REVIEW = "INSURER_REVIEW",

View File

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

View File

@@ -1,6 +1,9 @@
export enum ClaimRequiredDocumentType {
// 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_CARD = "national_card",
// Damaged party documents
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",

View File

@@ -2,7 +2,10 @@ export enum RoleEnum {
EXPERT = "expert",
DAMAGE_EXPERT = "damage_expert",
FIELD_EXPERT = "field_expert",
REGISTRAR = "registrar",
COMPANY = "company",
ADMIN = "admin",
USER = "user",
FILE_MAKER = "file_maker",
FILE_REVIEWER = "file_reviewer",
}

View File

@@ -4,15 +4,13 @@ import {
HttpException,
HttpStatus,
Injectable,
Logger,
OnModuleInit,
} from "@nestjs/common";
import axios, { AxiosRequestConfig } from "axios";
import * as FormData from "form-data";
import { ConfigService } from "@nestjs/config";
import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
@Injectable()
export class AiService implements OnModuleInit {
private readonly logger = new Logger(AiService.name);
private apiKey: string;
private accessToken: string = null;
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
private readonly loginOptions: AxiosRequestConfig = {
method: "POST",
headers: { "Content-Type": "application/json" },
url: `${process.env.AI_URL_V2}/auth/login`,
url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
data: {
username: process.env.AI_USERNAME,
password: process.env.AI_PASSWORD,
username: this.configService.get<string>("AI_USERNAME"),
password: this.configService.get<string>("AI_PASSWORD"),
},
timeout: 30000, // 30 second timeout
timeout: 1000, // TODO: Make this ENV
};
constructor(private readonly configService: ConfigService) {}
private get profileOptions(): AxiosRequestConfig {
return {
method: "GET",
url: `${process.env.AI_URL_V2}/auth/profile`,
url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
};
}
constructor() {}
async onModuleInit() {
try {
const res = await this.login();
if (res?.accessToken) {
this.logger.verbose("AI Service Authenticated Successfully.");
this.accessToken = res.accessToken;
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.",
);
}
// if (res?.accessToken) {
// this.accessToken = res.accessToken;
// await this.getApiKey();
// }
} catch (error) {
// Don't prevent app startup if AI service is temporarily unavailable
// 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
this.accessToken = null;
this.apiKey = null;
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
}
private async login() {
try {
const loginResponse = await axios.request(this.loginOptions);
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,
);
}
// const loginResponse = await axios.request(this.loginOptions);
// return loginResponse.data;
}
private async getApiKey() {
try {
const profileResponse = await axios.request(this.profileOptions);
this.apiKey = profileResponse.data.apiKey.key;
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,
);
}
// const profileResponse = await axios.request(this.profileOptions);
// this.apiKey = profileResponse.data.apiKey.key;
// return this.apiKey;
}
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
if (!this.accessToken || !this.apiKey) {
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
try {
const res = await this.login();
if (res?.accessToken) {
this.accessToken = res.accessToken;
await this.getApiKey();
} else {
throw new HttpException(
"AI Service authentication failed",
HttpStatus.UNAUTHORIZED,
);
}
// if (res?.accessToken) {
// this.accessToken = res.accessToken;
// await this.getApiKey();
// } else {
// throw new HttpException(
// "AI Service authentication failed",
// HttpStatus.UNAUTHORIZED,
// );
// }
} catch (error) {
this.logger.error("Failed to re-authenticate AI service:", error.message);
throw new HttpException(
"AI Service authentication failed",
HttpStatus.UNAUTHORIZED,
@@ -140,100 +107,69 @@ export class AiService implements OnModuleInit {
? file.path
: join(process.cwd(), file.path.replace(/^\.\//, ""));
this.logger.log(`Processing AI image request for: ${filePath}`);
// Check if file exists
if (!existsSync(filePath)) {
this.logger.error(`File not found at path: ${filePath}`);
throw new HttpException(
`File not found: ${file.path}`,
HttpStatus.NOT_FOUND,
);
}
const form = new FormData();
// const form = new FormData();
const fileStream = createReadStream(filePath);
// Append file with filename if available
if (file.fileName) {
form.append("images", fileStream, file.fileName);
// form.append("images", fileStream, file.fileName);
} else {
// Extract filename from path if not provided
const pathParts = filePath.split("/");
const extractedFileName = pathParts[pathParts.length - 1];
form.append("images", fileStream, extractedFileName);
// form.append("images", fileStream, extractedFileName);
}
try {
const requestHeaders = {
...this.imageProcessOptions.headers,
...form.getHeaders(),
// ...form.getHeaders(),
};
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`);
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 fs = require("fs");
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
if (!response.data) {
this.logger.error(`[ERROR] AI response is empty or missing data`);
throw new HttpException(
"AI Service returned empty response",
HttpStatus.BAD_GATEWAY,
);
}
// if (!response.data) {
// throw new HttpException(
// "AI Service returned empty response",
// HttpStatus.BAD_GATEWAY,
// );
// }
// Check for error in response first (AI service returns 201 with error in body)
if (response.data.error) {
this.logger.error(`[ERROR] AI service returned an error in response body`);
this.logger.error(`[ERROR] Error message: ${response.data.error}`);
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
throw new HttpException(
`AI Service error: ${response.data.error}`,
HttpStatus.BAD_GATEWAY,
);
}
// // Check for error in response first (AI service returns 201 with error in body)
// if (response.data.error) {
// throw new HttpException(
// `AI Service error: ${response.data.error}`,
// HttpStatus.BAD_GATEWAY,
// );
// }
// Check for processed image (downloadLink)
if (!response.data.downloadLink) {
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`);
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`);
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
throw new HttpException(
"AI Service did not return processed image (downloadLink missing)",
HttpStatus.BAD_GATEWAY,
);
}
// // Check for processed image (downloadLink)
// if (!response.data.downloadLink) {
// throw new HttpException(
// "AI Service did not return processed image (downloadLink missing)",
// HttpStatus.BAD_GATEWAY,
// );
// }
// Check for reports
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;
// return response.data;
} catch (er) {
// Determine error source
let errorSource = "UNKNOWN";
@@ -242,7 +178,10 @@ export class AiService implements OnModuleInit {
if (er.response) {
errorSource = "AI_SERVICE_RESPONSE";
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`;
errorMessage =
er.response?.data?.message ||
er.message ||
`HTTP ${er.response.status}`;
errorDetails = er.response?.data
? JSON.stringify(er.response.data, null, 2)
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
@@ -255,14 +194,6 @@ export class AiService implements OnModuleInit {
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
throw new HttpException(
`[${errorSource}] ${errorMessage}`,

View File

@@ -1,51 +1,46 @@
import { join } from "node:path";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
import { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule";
import { ServeStaticModule } from "@nestjs/serve-static";
import * as dotenv from "dotenv";
import { CommandModule } from "nestjs-command";
import { AiModule } from "./ai/ai.module";
import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
import { ClientModule } from "./client/client.module";
import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module";
import { ExpertClaimModule } from "./expert-claim/expert-claim.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 { PlatesModule } from "./plates/plates.module";
import { ProfileModule } from "./profile/profile.module";
import { SandHubModule } from "./sand-hub/sand-hub.module";
import { SystemSettingsModule } from "./system-settings/system-settings.module";
import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
dotenv.config();
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
import { DatabaseModule } from "./core/database/database.module";
import { AppConfigModule } from "./core/config/config.module";
@Module({
imports: [
CommandModule,
ScheduleModule.forRoot(),
AppConfigModule,
DatabaseModule,
CronModule,
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",
}),
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,
},
),
UsersModule,
AuthModule,
ClientModule,
@@ -53,9 +48,12 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
PlatesModule,
RequestManagementModule,
SandHubModule,
SystemSettingsModule,
ExpertBlameModule,
ClaimRequestManagementModule,
FanavaranModule,
ExpertClaimModule,
CaseExpertReportModule,
AiModule,
ReportsModule,
ExpertInsurerModule,
@@ -63,6 +61,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
WorkflowStepManagementModule,
],
controllers: [],
providers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: UnicodeDigitsNormalizeInterceptor,
},
// {
// provide: APP_PIPE,
// useValue: new ValidationPipe({
// transform: true,
// whitelist: true,
// forbidNonWhitelisted: false,
// }),
// },
],
})
export class AppModule {}

View File

@@ -5,17 +5,24 @@ import {
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import {
ApiBody,
ApiAcceptedResponse,
ApiOperation,
ApiResponse,
ApiTags,
ApiBearerAuth,
} from "@nestjs/swagger";
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 {
ForgetPasswordSendCodeDto,
ForgetPasswordVerifyCodeDto,
@@ -23,28 +30,93 @@ import {
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
import {
GenuineRegisterDto,
InsurerRegisterDto,
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
@Controller("actor")
@ApiTags("actor")
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")
@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 })
async registerGenuine(@Body() body: GenuineRegisterDto) {
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")
@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 })
async registerLegal(@Body() body: LegalRegisterDto) {
return await this.actorAuthService.legalRegister(body);
@@ -64,16 +136,86 @@ export class ActorAuthController {
return await this.actorAuthService.createFieldExpertMock(body);
}
/** Mock: create a registrar for testing. Make private later. */
@Post("create-registrar")
@ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) {
return await this.actorAuthService.createRegistrarMock(body);
}
@UseGuards(LocalActorAuthGuard)
@Post("login")
@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({
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()
async login(@Body() body, @Req() req, @ClientKey() client) {
return await this.actorAuthService.loginActors(req.user);
async login(@Req() req: { user: Record<string, unknown> }) {
return req.user;
}
@Post("forget-password")

View File

@@ -22,11 +22,15 @@ export class UserAuthController {
@Post("/send-otp")
@ApiBody({
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()
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) {
throw new HttpException(res, HttpStatus.ACCEPTED);
}
@@ -36,7 +40,8 @@ export class UserAuthController {
@UseGuards(LocalUserAuthGuard)
@ApiBody({
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()
async login(@Body() body, @Req() req, @CurrentUser() user) {

View File

@@ -18,6 +18,7 @@ import {
RegisterDtoRs,
} from "src/auth/dto/actor/register.actor.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 { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
@@ -25,9 +26,11 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { 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 { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service";
import { OtpService } from "src/utils/otp/otp.service";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {};
@@ -46,10 +49,13 @@ export class ActorAuthService {
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService,
private readonly otpService: OtpGeneratorService,
private readonly captchaChallengeService: CaptchaChallengeService,
private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService,
) {}
// TODO convrt to class for dynamic controller
@@ -61,7 +67,7 @@ export class ActorAuthService {
res = await this.expertDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.expertDbService.findOne({ email: username });
else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
break;
case RoleEnum.DAMAGE_EXPERT:
if (username == null && userId)
@@ -69,7 +75,10 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId),
});
else
res = await this.damageExpertDbService.findOne({ email: username });
res = await this.findActorByLoginIdentifier(
this.damageExpertDbService,
username,
);
break;
case RoleEnum.FIELD_EXPERT:
if (username == null && userId)
@@ -77,59 +86,147 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId),
});
else
res = await this.fieldExpertDbService.findOne({ email: username });
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
break;
case RoleEnum.REGISTRAR:
if (username == null && userId)
res = await this.registrarDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.registrarDbService.findOne({ email: username });
break;
case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username });
break;
case RoleEnum.FILE_MAKER:
if (username == null && userId)
res = await this.fileMakerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.fileMakerDbService.findByLoginIdentifier(username);
break;
case RoleEnum.FILE_REVIEWER:
if (username == null && userId)
res = await this.fileReviewerDbService.findOne({
_id: new Types.ObjectId(userId),
});
else
res =
await this.fileReviewerDbService.findByLoginIdentifier(username);
break;
default:
return null;
}
return res;
}
async validateActor(username: string, pass: string, role): Promise<any> {
/** Normalizes `role` from login body (string or single-element array). */
parseActorLoginRole(role: unknown): RoleEnum {
const raw = Array.isArray(role) ? role[0] : role;
if (
typeof raw !== "string" ||
!Object.values(RoleEnum).includes(raw as RoleEnum)
) {
throw new BadRequestException(
`Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`,
);
}
return raw as RoleEnum;
}
parseActorLoginUsername(body: Record<string, unknown>): string {
const username = body?.username ?? body?.email ?? body?.nationalCode;
if (typeof username !== "string" || !username.trim()) {
throw new BadRequestException(
"username, email, or nationalCode is required",
);
}
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) {
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",
);
} else {
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");
}
return null;
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) {
let foundedUser = await this.dynamicDbController(user.role, user.username);
if (foundedUser) {
const payload = {
username: foundedUser.username || foundedUser.email,
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");
if (user?.access_token && user?.sub) {
return user;
}
return this.loginFromCredentials(user as Record<string, unknown>);
}
async registerActors(
@@ -164,7 +261,7 @@ export class ActorAuthService {
firstName: body.firstName,
lastName: body.lastName,
phone: body.phone,
mobile:body.mobile,
mobile: body.mobile,
city: body.city,
state: body.state,
address: body.address,
@@ -273,6 +370,10 @@ export class ActorAuthService {
actor = await this.fieldExpertDbService.findOne(filter);
dbServiceToUpdate = this.fieldExpertDbService as any;
}
if (!actor) {
actor = await this.registrarDbService.findOne(filter);
dbServiceToUpdate = this.registrarDbService as any;
}
if (!actor) {
throw new NotFoundException("actor not found");
@@ -309,6 +410,10 @@ export class ActorAuthService {
userExist = await this.fieldExpertDbService.findOne({ email });
dbServiceToUpdate = this.fieldExpertDbService;
}
if (!userExist) {
userExist = await this.registrarDbService.findOne({ email });
dbServiceToUpdate = this.registrarDbService as any;
}
if (!userExist) throw new NotFoundException("user not found");
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
@@ -376,13 +481,8 @@ export class ActorAuthService {
"state",
"address",
],
field_expert: [
"firstName",
"lastName",
"email",
"phone",
"mobile",
],
field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
registrar: ["email"],
};
const allowedFields = allowedFieldsByRole[role];
@@ -407,7 +507,11 @@ export class ActorAuthService {
}
// 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");
@@ -498,4 +602,34 @@ export class ActorAuthService {
throw er;
}
}
async createRegistrarMock(body: {
email: string;
password: string;
clientId: string;
}) {
const hashPassword = await this.hashService.hash(body.password);
const payload = {
email: body.email.toLowerCase().trim(),
password: hashPassword,
clientKey: new Types.ObjectId(body.clientId),
role: RoleEnum.REGISTRAR,
};
try {
const created = await this.registrarDbService.create(payload as any);
return {
id: (created as any)._id,
email: (created as any).email,
clientKey: (created as any).clientKey,
role: (created as any).role,
};
} catch (er) {
if (er.code === 11000) {
throw new BadRequestException(
"A registrar with this email already exists.",
);
}
throw er;
}
}
}

View File

@@ -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 {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotAcceptableException,
NotFoundException,
} from "@nestjs/common";
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
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 {
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 { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
export interface LinkBinding {
linkToken?: string;
linkContext?: string;
}
@Injectable()
export class UserAuthService {
private readonly logger = new Logger(UserAuthService.name);
@@ -24,18 +36,32 @@ export class UserAuthService {
private readonly jwtService: JwtService,
private readonly userDbService: UserDbService,
private readonly hashService: HashService,
private readonly otpCreator: OtpService,
private readonly smsManagerService: SmsManagerService,
private readonly otpCreator: OtpGeneratorService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userLinkAccessService: UserLinkAccessService,
) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.userDbService.findOne({ username });
if (!user) throw new NotFoundException("user not found");
async validateUser(
username: string,
pass: string,
binding: LinkBinding = {},
): Promise<any> {
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
const now = new Date().getTime();
if (user.otp == null) throw new NotAcceptableException("please get otp");
if (user.otpExpire < now) {
throw new NotAcceptableException("expire otp");
await this.userLinkAccessService.assertMobileAllowed({
mobile: canonicalMobile,
linkToken: binding.linkToken,
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)) {
return user;
@@ -44,39 +70,59 @@ export class UserAuthService {
}
async login(user: any) {
const userId = String(user._id ?? user.id ?? "");
const payload = {
username: user.username,
sub: user.id,
sub: userId,
role: "user",
};
const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`,
secret: `${process.env.JWT_SECRET}`,
});
await this.userDbService.findOneAndUpdate(
{ username: user.username },
{
tokens: { token: accToken },
otp: null,
otpExpire: 0,
},
);
return {
userId: user._id,
userId,
access_token: accToken,
};
}
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> {
const userExist = await this.userDbService.findOne({
mobile,
async sendOtpRequest(
mobile: string,
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 expireMinutes = readOtpExpireMinutesFromEnv();
const nowMs = Date.now();
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
if (!userExist) {
await this.smsSender(otp, mobile);
/// create otp request
await this.smsSender(otp, canonicalMobile);
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
const newUser = await this.userDbService.createUser({
mobile,
username: mobile,
mobile: canonicalMobile,
username: canonicalMobile,
otp: hashOtp,
tokens: {
token: "",
@@ -90,50 +136,57 @@ export class UserAuthService {
city: "",
address: "",
state: "",
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
});
return new LoginDtoRs(newUser);
}
if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish");
await this.smsSender(otp, mobile);
const updateTokens = await this.userDbService.findOneAndUpdate(
{
username: userExist.username,
},
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
// throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
return new LoginDtoRs(userExist, "OTP Still valid");
}
await this.smsSender(otp, canonicalMobile);
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
await this.userDbService.findOneAndUpdate(
buildUserLookupByPhone(canonicalMobile),
{
otp: hashOtp,
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
mobile: canonicalMobile,
username: userExist.username || canonicalMobile,
},
);
if (updateTokens) return new LoginDtoRs(userExist);
return new LoginDtoRs(userExist);
}
private createOtpForRequest(): string {
if (isFakeOtpEnabled()) {
this.logger.warn(
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
);
return FAKE_OTP_CODE;
}
return this.otpCreator.create();
}
private async smsSender(otp: string, mobile: string) {
return this.smsManagerService
.verifyLookUp({
token: otp,
template: process.env.AUTH_SMS_TEMPLATE,
receptor: mobile,
})
.then((smsRes) => {
if (isFakeOtpEnabled()) {
this.logger.log(
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
);
})
.catch((er) => {
this.logger.error(
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
return;
}
const ok = await this.smsOrchestrationService.sendAuthOtp(
mobile,
otp,
process.env.AUTH_SMS_TEMPLATE,
);
throw new HttpException(
" auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR,
if (!ok) {
throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
}
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
);
});
}
}

View File

@@ -1,42 +1,74 @@
import { Module } from "@nestjs/common";
import { Global, Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
import { MongooseModule } from "@nestjs/mongoose";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
import { ActorAuthService } from "src/auth/auth-services/actor.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 {
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 { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { CaptchaModule } from "src/captcha/captcha.module";
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
@Global()
@Module({
imports: [
// MailModule, // Mailer disabled not used
UsersModule,
ClientModule,
HashModule,
OtpModule,
PassportModule,
SmsManagerModule,
CaptchaModule,
SmsOrchestrationModule,
MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
{ name: BlameRequest.name, schema: BlameRequestSchema },
{
name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema,
},
{ name: ClaimCase.name, schema: ClaimCaseSchema },
]),
JwtModule.register({
signOptions: { expiresIn: "1h" },
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
global: true,
secret: `${process.env.SECRET}`,
secret: `${process.env.JWT_SECRET}`,
}),
],
providers: [
UserAuthService,
UserLinkAccessService,
ActorAuthService,
LocalStrategy,
LocalActorStrategy,
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
],
exports: [
UserAuthService,
ActorAuthService,
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
],
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
controllers: [UserAuthController, ActorAuthController],
})
export class AuthModule {}

View File

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

View File

@@ -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";
export class LoginActorDto {
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
role: RoleEnum[];
@ApiProperty({})
username: string;
@ApiPropertyOptional({
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({})
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 {

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";
export class UserLoginDto {
@ApiProperty({
example: "09226187419",
type: "string",
description: "User login dto",
description: "Mobile number (username for OTP login)",
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
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 {
@ApiProperty({ type: "string" })
message: string;
@ApiProperty({
example: "09226187419",
type: "string",
@@ -37,8 +63,10 @@ export class LoginDtoRs extends UserModel {
username: string;
mobile: string;
nationalCode: string;
constructor(loginData) {
constructor(loginData, message: string = "") {
super();
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 {
@ApiProperty({
example: "09226187419",
type: "string",
description: "User login dto",
description: "Mobile number (same value sent to send-otp)",
})
@IsString()
@IsNotEmpty()
@MaxLength(20)
username: string;
@ApiProperty({
example: "258567",
type: "string",
description: "User login verify dto",
description: "OTP code from SMS",
})
@IsString()
@IsNotEmpty()
@MaxLength(16)
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 {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { AuthGuard } from "@nestjs/passport";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Injectable()
export class LocalActorAuthGuard extends AuthGuard("actor") {
export class LocalActorAuthGuard implements CanActivate {
constructor(
private readonly actorAuthService: ActorAuthService,
private readonly jwtService: JwtService,
) {
super();
}
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
const path = request.url;
if (!token) {
if (path === "/actor/login") {
const loginData = await this.actorAuthService.loginActors(request.body);
if (this.isActorLoginRequest(request)) {
const loginData = await this.actorAuthService.loginFromCredentials(
request.body ?? {},
);
request.user = loginData;
request.identity = request;
request.identity = loginData;
return true;
} else {
throw new UnauthorizedException("Token not found");
}
throw new UnauthorizedException("Token not found");
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`,
secret: `${process.env.JWT_SECRET}`,
});
if (
@@ -44,6 +42,9 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
RoleEnum.DAMAGE_EXPERT,
RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER,
].includes(payload.role)
) {
throw new UnauthorizedException("User role is not authorized");
@@ -58,9 +59,21 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
//@ts-ignore
const [type, token] = request.headers.authorization?.split(" ") ?? [];
private isActorLoginRequest(request: {
url?: string;
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;
}
}

View File

@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
try {
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)
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
throw new UnauthorizedException("Invalid role");
} catch (error) {
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
if (
error instanceof ForbiddenException ||
error instanceof UnauthorizedException
) {
throw error;
}
throw new UnauthorizedException();
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
return type === "Bearer" ? token : undefined;
}
}

View File

@@ -8,31 +8,44 @@ import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
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()
export class GlobalGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
throw new UnauthorizedException("Missing Bearer token");
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`,
secret: `${process.env.JWT_SECRET}`,
});
if (payload.role !== RoleEnum.USER) {
console.log(
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
request.user.role,
if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) {
throw new UnauthorizedException(
`Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`,
);
throw new UnauthorizedException();
}
request.user = payload;
request.identity = request.user;
} catch {
throw new UnauthorizedException();
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException("Invalid or expired token");
}
return true;
}

View File

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

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

View File

@@ -6,11 +6,17 @@ import { RequestManagementModule } from "src/request-management/request-manageme
import { UsersModule } from "src/users/users.module";
import { ClaimRequestManagementController } from "./claim-request-management.controller";
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.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 { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.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 { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -45,15 +51,23 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { HttpModule } from "@nestjs/axios";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
@Module({
imports: [
HttpModule,
FanavaranAuditModule,
FanavaranLookupModule,
PublicIdModule,
UsersModule,
RequestManagementModule,
AiModule,
SandHubModule,
ClientModule,
MediaPolicyModule,
JwtModule.register({}),
MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema },
@@ -85,7 +99,13 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequiredDocumentDbService,
ClaimAccessGuard,
],
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
controllers: [
ClaimRequestManagementController,
ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller,
ExpertInitiatedClaimMirrorController,
RegistrarClaimMirrorController,
],
exports: [
ClaimRequestManagementService,
ClaimRequestManagementDbService,
@@ -93,6 +113,7 @@ import { JwtModule } from "@nestjs/jwt";
DamageImageDbService,
VideoCaptureDbService,
ClaimRequiredDocumentDbService,
ClaimSignDbService,
],
})
export class ClaimRequestManagementModule {}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
/**
* DTO for required document item
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'],
})
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 {
@ApiProperty({
description: 'Part key',
example: 'hood',
description:
'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({
description: 'Display label in Farsi',
@@ -82,6 +97,12 @@ export class DamagedPartItem {
})
label_fa: string;
@ApiPropertyOptional({
description: 'Deprecated: same as `name` (kept for older clients)',
example: 'backfender',
})
key?: string;
@ApiProperty({
description: 'Display label in English',
example: 'Hood',
@@ -93,6 +114,12 @@ export class DamagedPartItem {
example: false,
})
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;
@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({
description: 'List of required documents to upload',
type: [RequiredDocumentItem],
@@ -159,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
anglesTotal: number;
partsCaptured: number;
partsTotal: number;
capturePhaseDocsRemaining: number;
postCaptureDocumentsRemaining: number;
};
}

View File

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

View File

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

View File

@@ -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 {
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
@@ -10,7 +10,11 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim request number', example: 'CL12345' })
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;
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
@@ -30,6 +34,17 @@ export class GetMyClaimsV2ResponseDto {
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count', example: 5 })
@ApiProperty({ description: 'Total count after search filter', example: 5 })
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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
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";
/**
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
* with nested validation enabled for the v2 controller pipeline.
*/
export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => 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 {
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";
export class UserObjectionPartDto {
@ApiProperty()
partId: string;
@ApiProperty({
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;
@ApiProperty({ required: false })
@ApiPropertyOptional({
description: "Owner-proposed part price (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partPrice?: string;
@ApiProperty({ required: false })
@ApiPropertyOptional({
description: "Owner-proposed salary / labor (Toman, integer string).",
})
@IsOptional()
@IsString()
@MaxLength(32)
@IsRepairLineAmountToman()
partSalary?: string;
@ApiProperty({ required: false })
@ApiPropertyOptional({ enum: TypeOfDamage })
@IsOptional()
@IsEnum(TypeOfDamage)
typeOfDamage?: TypeOfDamage;
@ApiProperty({ required: false })
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
carPartDamage?: string;
@ApiProperty({ required: false })
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
side?: string;
}
export class NewPartDto {
@ApiProperty({ required: false, nullable: true })
partId: string | null;
@ApiPropertyOptional({
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;
@ApiProperty({ required: false })
@ApiPropertyOptional({ example: "front" })
@IsOptional()
@IsString()
@MaxLength(64)
side?: string;
}
export class UserObjectionDto {
@ApiProperty({ type: [UserObjectionPartDto], required: false })
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[];
@ApiProperty({ type: [NewPartDto], required: false })
@ApiPropertyOptional({ type: [NewPartDto] })
@Validate(HasObjectionEntriesConstraint)
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto)
newParts?: NewPartDto[];
}

View File

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

View File

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

View File

@@ -1,15 +1,24 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "mongoose";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
@Schema({ _id: false })
export class ClaimPartPricing {
@Prop({ type: String })
partId: string;
@Prop({ type: Number })
partId: number;
@Prop({ type: String })
carPartDamage?: string;
/**
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
* Must be Mixed — objects cannot cast to String.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carPartDamage?: unknown;
@Prop({ type: String })
typeOfDamage?: string;
@@ -23,8 +32,9 @@ export class ClaimPartPricing {
@Prop({ type: MongooseSchema.Types.Mixed })
totalPayment?: string | number;
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
@Prop({ type: MongooseSchema.Types.Mixed })
daghi?: string | number;
daghi?: string | number | Record<string, unknown>;
@Prop({ type: Boolean, default: false })
factorNeeded?: boolean;
@@ -67,6 +77,43 @@ export class ClaimUserComment {
export const ClaimUserCommentSchema =
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 })
export class ClaimExpertReply {
@Prop({ type: String })
@@ -83,14 +130,19 @@ export class ClaimExpertReply {
@Prop({ type: ClaimUserCommentSchema })
userComment?: ClaimUserComment;
/** Damage expert profile at reply time (from `damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
}
export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply);
/** One line item the user disputes on an expert-priced part */
@Schema({ _id: false })
export class ClaimUserObjection {
@Prop({ type: String, required: true })
partId: string;
export class ClaimUserObjectionPart {
@Prop({ type: Number, required: true })
partId: number;
@Prop({ type: String })
reason?: string;
@@ -103,9 +155,48 @@ export class ClaimUserObjection {
@Prop({ type: String })
typeOfDamage?: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionSchema =
SchemaFactory.createForClass(ClaimUserObjection);
export const ClaimUserObjectionPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionPart);
@Schema({ _id: false })
export class ClaimUserObjectionNewPart {
/** Catalog id when known; otherwise a generated string id for the new line. */
@Prop({ type: MongooseSchema.Types.Mixed })
partId?: number | string;
@Prop({ type: String, required: true })
partName: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
/**
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}.
*/
@Schema({ _id: false })
export class ClaimUserObjectionPayload {
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
objectionParts?: ClaimUserObjectionPart[];
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
newParts?: ClaimUserObjectionNewPart[];
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
}
export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload);
@Schema({ _id: false })
export class ClaimResendRequest {
@@ -117,6 +208,17 @@ export class ClaimResendRequest {
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendCarParts?: any[];
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
@Prop({ type: Date })
fulfilledAt?: Date;
/** Damage expert profile when resend was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
@Prop({ type: Types.ObjectId })
requestedByExpertId?: Types.ObjectId;
}
export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest);
@@ -145,6 +247,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
}
export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating);
@@ -165,6 +270,12 @@ export class ClaimPriceDrop {
@Prop({ type: 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);
@@ -182,8 +293,8 @@ export class ClaimEvaluation {
@Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema })
objection?: ClaimUserObjection;
@Prop({ type: ClaimUserObjectionPayloadSchema })
objection?: ClaimUserObjectionPayload;
@Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload;
@@ -191,11 +302,25 @@ export class ClaimEvaluation {
@Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating;
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
@Prop({ type: String })
visitLocation?: string;
@Prop({ type: ClaimPriceDropSchema })
priceDrop?: ClaimPriceDrop;
/** Damage expert profile at last factor validation (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot;
/** Damage expert profile when in-person visit was requested (`damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot;
}
export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation);

View File

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

View File

@@ -1,5 +1,5 @@
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 { CarGreenCardModel } from "./car-green-card.schema";
import { ImageRequiredModel } from "./image-required.schema";
@@ -20,6 +20,39 @@ export class 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 })
export class ClaimMedia {
@Prop({ type: CarGreenCardModel })
@@ -46,15 +79,10 @@ export class ClaimMedia {
carAngles?: Map<string, CapturedImage>;
/**
* V2: Damaged parts captures
* Map of part key to captured image
* V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
* Legacy documents may store a plain object map keyed by part slug until migrated on write.
*/
@Prop({
type: Map,
of: CapturedImageSchema,
default: () => ({}),
})
damagedParts?: Map<string, CapturedImage>;
@Prop({ type: MongooseSchema.Types.Mixed })
damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
}
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);

View File

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

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 { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.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 {
ClaimDamageSelection,
ClaimDamageSelectionSchema,
@@ -25,7 +28,15 @@ import {
ClaimCaseSnapshot,
ClaimCaseSnapshotSchema,
} 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 {
CaseInquiries,
CaseInquiriesSchema,
} from "src/common/schema/case-inquiries.schema";
@Schema({ _id: false })
export class RequiredDocumentRef {
@@ -44,7 +55,57 @@ export class RequiredDocumentRef {
@Prop({ type: 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;
}
export const FanavaranSyncStageSchema =
SchemaFactory.createForClass(FanavaranSyncStage);
@Schema({ _id: false })
export class FanavaranSyncState {
@Prop({ type: FanavaranSyncStageSchema })
baseClaim?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
damageCase?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
attachments?: FanavaranSyncStage;
@Prop({ type: FanavaranSyncStageSchema })
expertise?: FanavaranSyncStage;
}
export const FanavaranSyncStateSchema =
SchemaFactory.createForClass(FanavaranSyncState);
@Schema({
collection: "claimCases",
@@ -66,7 +127,12 @@ export class ClaimCase {
/**
* 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;
/**
@@ -75,6 +141,12 @@ export class ClaimCase {
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus;
/**
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
*/
@Prop({ default: false })
blameDocumentResendPending?: boolean;
/**
* Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
@@ -85,6 +157,22 @@ export class ClaimCase {
@Prop({ type: String, index: true })
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: () => ({}) })
workflow?: ClaimWorkflow;
@@ -103,6 +191,15 @@ export class ClaimCase {
@Prop({ type: Number })
claimId?: number;
@Prop({ type: Number })
dmgCaseId?: number;
@Prop({ type: Number })
expertiseId?: number;
@Prop({ type: FanavaranSyncStateSchema, default: () => ({}) })
fanavaranSync?: FanavaranSyncState;
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
damage?: ClaimDamageSelection;
@@ -112,6 +209,9 @@ export class ClaimCase {
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
evaluation?: ClaimEvaluation;
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
inquiries?: CaseInquiries;
/**
* Optional “read-optimized” copy of fields from blame/request side.
* Source of truth remains `blameRequestId`.
@@ -133,6 +233,15 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[];
/**
* User satisfaction rating (damaged party), after the case is completed.
*/
@Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating;
@Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId;
/**
* Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these.
@@ -143,5 +252,4 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

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

View File

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

View File

@@ -0,0 +1,565 @@
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";
/**
* 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,
);
}
@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

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

View File

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

@@ -1,31 +1,31 @@
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
import {
Body,
Controller,
Get,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { GlobalGuard } from "src/auth/guards/global.guard";
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { CurrentUser } from "src/decorators/user.decorator";
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 { ClientDto } from "./dto/create-client.dto";
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
@Controller("client")
@ApiTags("client-management")
export class ClientController {
constructor(private readonly clientService: ClientService) {}
constructor(
private readonly clientService: ClientService,
private readonly systemSettingsService: SystemSettingsService,
) {}
@Post()
@UseGuards(GlobalGuard)
@ApiBearerAuth()
async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client);
}
@Get()
@ApiBearerAuth()
@UseGuards(GlobalGuard)
async getClient(@CurrentUser() user) {
return await this.clientService.getClients();
}
@@ -34,4 +34,33 @@ export class ClientController {
async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList();
}
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live")
@ApiOperation({
summary: "Enable or disable live external inquiries",
description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
})
@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 { MongooseModule } from "@nestjs/mongoose";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { ClientController } from "./client.controller";
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
import { ClientPanelController } from "./client-panel.controller";
import { ClientService } from "./client.service";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { BranchDbService } from "./entities/db-service/branch.db.service";
import { ClientDbService } from "./entities/db-service/client.db.service";
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
@@ -9,6 +13,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
@Module({
imports: [
SystemSettingsModule,
MongooseModule.forFeature([
{ name: ClientModel.name, schema: ClientDbSchema },
{
@@ -17,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
},
]),
],
controllers: [ClientController],
providers: [ClientService, ClientDbService, BranchDbService],
exports: [ClientService, ClientDbService, BranchDbService],
controllers: [
ClientController,
ClientPanelController,
ClientExternalInquiriesController,
],
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
})
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 {
ClientDto,
ClientDtoRs,
ClientLists,
} 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 {
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()
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> {
try {
const smsApiKey = client.property?.smsApiKey?.trim();
const newClient = await this.clientDbService.create({
clientCode: client.clientCode,
clientName: {
persian: client.clientName.persian,
english: client.clientName.english || null,
persian:
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,
},
useExpertMode: client.useExpertMode || null,
...(smsApiKey ? { property: { smsApiKey } } : {}),
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) {
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 });
}
/**
* 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[]> {
const clients = await this.clientDbService.findAll();
const show = clients.map((c) => new ClientDtoRs(c));
@@ -53,4 +201,282 @@ export class ClientService {
const list = client.map((element) => new ClientLists(element));
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,154 @@
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;
}
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 { IsString, IsNotEmpty, IsOptional } from "class-validator";
import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from "class-validator";
export class CreateBranchDto {
@ApiProperty({ example: "شهرک غرب" })
@@ -31,4 +37,18 @@ export class CreateBranchDto {
@IsOptional()
@IsString()
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 } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { Types } from "mongoose";
class ClientName {
@ApiProperty({})
@IsString()
@IsNotEmpty()
persian: string;
@ApiProperty({})
@IsString()
@IsNotEmpty()
english: string;
}
class Property {
@ApiProperty({})
smsApiKey: string;
@ApiPropertyOptional({})
@IsOptional()
@IsString()
smsApiKey?: string;
}
@Injectable()
export class ClientDto {
@ApiProperty({ required: true })
@ValidateNested()
@Type(() => ClientName)
clientName: ClientName;
@ApiProperty({ required: true })
@IsNumber()
clientCode: number;
@ApiProperty({ required: false })
property: Property;
@IsOptional()
@ValidateNested()
@Type(() => Property)
property?: Property;
@ApiProperty({ examples: ["legal", "genuine"] })
@IsIn(["legal", "genuine"])
useExpertMode: "legal" | "genuine";
}
export class ClientDtoRs {
@@ -35,6 +55,9 @@ export class ClientDtoRs {
useExpertsMode: string;
constructor(readonly client) {
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> {
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 { FilterQuery, Model } from "mongoose";
import { FilterQuery, Model, UpdateQuery } from "mongoose";
import { ClientModel, ClientDocument } from "../schema/client.schema";
@Injectable()
export class ClientDbService {
export class ClientDbService implements OnModuleInit {
private readonly logger = new Logger(ClientDbService.name);
constructor(
@InjectModel(ClientModel.name)
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> {
return await this.clientModel.create(client);
}
@@ -25,4 +59,14 @@ export class ClientDbService {
async findAll(): Promise<ClientModel[]> {
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();
}
}

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