Compare commits

...

364 Commits

Author SHA1 Message Date
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
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
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
321 changed files with 47808 additions and 17976 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 # api
Current JWT payload:
```json
{
"username": "saman_insurer@gmail.com",
"sub": "6a144979799f3c63aa63f67c",
"fullName": "بیمه گر سامان",
"role": "company",
"userType": "UserType",
"clientKey": "67f0fd0e53868dc1ff8a2738",
"iat": 1781339791,
"exp": 1781343391
}
```

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":""}}

17205
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

@@ -1,19 +1,21 @@
//! NEW //! NEW
export enum CaseStatus { export enum CaseStatus {
OPEN = "OPEN", OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", 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", CAR_CERTIFICATE = "carCertificate",
DRIVING_LICENSE = "drivingLicense", DRIVING_LICENSE = "drivingLicense",
CAR_GREEN_CARD = "carGreenCard", CAR_GREEN_CARD = "carGreenCard",
CAR_PLATE = "carPlate",
CHASSIS_NUMBER = "chassisNumber",
// Media evidence // Media evidence
VOICE = "voice", VOICE = "voice",
VIDEO = "video",
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
DESCRIPTION = "description",
} }

View File

@@ -10,11 +10,30 @@ export enum ClaimCaseStatus {
// User flow - damage capture // User flow - damage capture
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES", 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 // Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT", WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
EXPERT_REVIEWING = "EXPERT_REVIEWING", EXPERT_REVIEWING = "EXPERT_REVIEWING",
/**
* @deprecated Prefer specific post-expert statuses below. Kept for existing DB rows and reads.
* Historically used for all owner/insurer steps after expert pricing.
*/
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL", WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
/** Expert reply has only priced lines (`factorNeeded=false` everywhere). Owner final accept/reject at `INSURER_REVIEW`. Also set after expert finishes repair-factor validation when the case returns to final owner sign-off. */
INSURER_REVIEW_AWAITING_OWNER_SIGN = "INSURER_REVIEW_AWAITING_OWNER_SIGN",
/** Expert reply mixes priced lines and factor-needed lines: owner signs priced lines, then uploads factors (status stays through both sub-steps). */
INSURER_REVIEW_MIXED_FACTORS_PENDING = "INSURER_REVIEW_MIXED_FACTORS_PENDING",
/** Expert reply requires a repair-factor file for every line before cost validation. */
OWNER_REPAIR_FACTOR_UPLOAD_PENDING = "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
// Final states // Final states
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",

View File

@@ -6,20 +6,28 @@ export enum ClaimWorkflowStep {
// User: Damage selection phase // User: Damage selection phase
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS", SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS", SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
// User: Documentation phase // User: Damage capture phase (angles + per-part photos) — before document upload in v2
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User: Damage capture phase (per part)
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES", 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 = "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 = "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", EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
// Insurer approval // Insurer approval
INSURER_REVIEW = "INSURER_REVIEW", INSURER_REVIEW = "INSURER_REVIEW",

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ export enum RoleEnum {
EXPERT = "expert", EXPERT = "expert",
DAMAGE_EXPERT = "damage_expert", DAMAGE_EXPERT = "damage_expert",
FIELD_EXPERT = "field_expert", FIELD_EXPERT = "field_expert",
REGISTRAR = "registrar",
COMPANY = "company", COMPANY = "company",
ADMIN = "admin", ADMIN = "admin",
USER = "user", USER = "user",

View File

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

View File

@@ -1,51 +1,45 @@
import { join } from "node:path"; import { join } from "node:path";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
import * as dotenv from "dotenv";
import { CommandModule } from "nestjs-command";
import { AiModule } from "./ai/ai.module"; import { AiModule } from "./ai/ai.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
import { ClientModule } from "./client/client.module"; import { ClientModule } from "./client/client.module";
import { ExpertBlameModule } from "./expert-blame/expert-blame.module"; import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
import { FanavaranModule } from "./fanavaran/fanavaran.module";
import { ExpertClaimModule } from "./expert-claim/expert-claim.module"; import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module"; import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
import { LookupsModule } from "./lookups/lookups.module"; import { LookupsModule } from "./lookups/lookups.module";
import { PlatesModule } from "./plates/plates.module"; import { PlatesModule } from "./plates/plates.module";
import { ProfileModule } from "./profile/profile.module"; import { ProfileModule } from "./profile/profile.module";
import { SandHubModule } from "./sand-hub/sand-hub.module"; import { SandHubModule } from "./sand-hub/sand-hub.module";
import { SystemSettingsModule } from "./system-settings/system-settings.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module"; import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { DatabaseModule } from "./core/database/database.module";
dotenv.config(); import { AppConfigModule } from "./core/config/config.module";
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@Module({ @Module({
imports: [ imports: [
CommandModule, AppConfigModule,
ScheduleModule.forRoot(), DatabaseModule,
CronModule, CronModule,
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, "..", "files"), // process.cwd() is always the project/container root (/app in Docker),
// so the volume-mounted /app/files is resolved correctly regardless of
// where the compiled dist files live (__dirname would resolve to
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
rootPath: join(process.cwd(), "files"),
serveRoot: "/files", serveRoot: "/files",
}), }),
MongooseModule.forRoot(
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
{
dbName: "yara724",
autoIndex: true,
user: process.env.MONGO_USER,
pass: process.env.MONGO_PASS,
authMechanism: "SCRAM-SHA-256",
tls: true,
tlsAllowInvalidCertificates: true,
},
),
UsersModule, UsersModule,
AuthModule, AuthModule,
ClientModule, ClientModule,
@@ -53,8 +47,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
PlatesModule, PlatesModule,
RequestManagementModule, RequestManagementModule,
SandHubModule, SandHubModule,
SystemSettingsModule,
ExpertBlameModule, ExpertBlameModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
FanavaranModule,
ExpertClaimModule, ExpertClaimModule,
AiModule, AiModule,
ReportsModule, ReportsModule,
@@ -63,6 +59,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
WorkflowStepManagementModule, WorkflowStepManagementModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [
{
provide: APP_INTERCEPTOR,
useClass: UnicodeDigitsNormalizeInterceptor,
},
// {
// provide: APP_PIPE,
// useValue: new ValidationPipe({
// transform: true,
// whitelist: true,
// forbidNonWhitelisted: false,
// }),
// },
],
}) })
export class AppModule {} export class AppModule {}

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
RegisterDtoRs, RegisterDtoRs,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { StateListDtoRs } from "src/auth/dto/actor/states.dto"; import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service"; import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum"; import { UserType } from "src/Types&Enums/userType.enum";
@@ -25,9 +26,9 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { OtpService } from "src/utils/otp/otp.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -46,10 +47,11 @@ export class ActorAuthService {
private readonly expertDbService: ExpertDbService, private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService, private readonly otpService: OtpGeneratorService,
private readonly captchaChallengeService: CaptchaChallengeService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -61,7 +63,7 @@ export class ActorAuthService {
res = await this.expertDbService.findOne({ res = await this.expertDbService.findOne({
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else res = await this.expertDbService.findOne({ email: username }); else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
break; break;
case RoleEnum.DAMAGE_EXPERT: case RoleEnum.DAMAGE_EXPERT:
if (username == null && userId) if (username == null && userId)
@@ -69,7 +71,10 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else
res = await this.damageExpertDbService.findOne({ email: username }); res = await this.findActorByLoginIdentifier(
this.damageExpertDbService,
username,
);
break; break;
case RoleEnum.FIELD_EXPERT: case RoleEnum.FIELD_EXPERT:
if (username == null && userId) if (username == null && userId)
@@ -77,7 +82,14 @@ export class ActorAuthService {
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else
res = await this.fieldExpertDbService.findOne({ email: username }); res = await this.fieldExpertDbService.findByLoginIdentifier(username);
break;
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; break;
case RoleEnum.COMPANY: case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username }); res = await this.insurerExpertDbService.findOne({ email: username });
@@ -88,48 +100,113 @@ export class ActorAuthService {
return res; return res;
} }
async validateActor(username: string, pass: string, role): Promise<any> { /** Normalizes `role` from login body (string or single-element array). */
const user = await this.dynamicDbController(role, username); parseActorLoginRole(role: unknown): RoleEnum {
if (user) { const raw = Array.isArray(role) ? role[0] : role;
if (user.role !== role) { if (
throw new UnauthorizedException("user not assigned to this role"); typeof raw !== "string" ||
} !Object.values(RoleEnum).includes(raw as RoleEnum)
if (!(await this.hashService.compare(pass, user.password))) { ) {
throw new UnauthorizedException( throw new BadRequestException(
"password is incorrect or access Denied", `Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`,
); );
} else {
return user;
}
} }
return null; return raw as RoleEnum;
} }
async loginActors(user: any) { parseActorLoginUsername(body: Record<string, unknown>): string {
let foundedUser = await this.dynamicDbController(user.role, user.username); const username = body?.username ?? body?.email ?? body?.nationalCode;
if (foundedUser) { if (typeof username !== "string" || !username.trim()) {
const payload = { throw new BadRequestException(
username: foundedUser.username || foundedUser.email, "username, email, or nationalCode is required",
sub: foundedUser._id, );
fullName:
`${foundedUser.firstName || ""} ${foundedUser.lastName || ""}`.trim(),
role: foundedUser.role || "User",
userType: foundedUser.userType || "UserType",
clientKey: foundedUser.clientKey || null,
};
const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`,
expiresIn: "1h",
});
return {
...payload,
access_token: accToken,
};
} else {
throw new UnauthorizedException("expert or damage_expert not found");
} }
return username.trim();
}
private async findActorByLoginIdentifier(
dbService: { findOne: (filter: any) => Promise<any> },
identifier: string,
) {
const id = identifier.trim();
const or: Record<string, string>[] = [{ email: id }, { username: id }];
if (/^\d{10}$/.test(id)) {
or.push({ nationalCode: id });
}
return dbService.findOne({ $or: or });
}
issueActorTokens(actor: {
_id: Types.ObjectId;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
role?: string;
userType?: string;
clientKey?: Types.ObjectId | string | null;
}) {
const payload = {
username:
actor.username || actor.email || (actor as any).nationalCode || null,
sub: actor._id,
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
role: actor.role || "User",
userType: actor.userType || "UserType",
clientKey: actor.clientKey ? String(actor.clientKey) : null,
};
const access_token = this.jwtService.sign(payload, {
secret: `${process.env.JWT_SECRET}`,
expiresIn: "1h",
});
return { ...payload, access_token };
}
async validateActor(
username: string,
pass: string,
role: RoleEnum,
): Promise<any> {
const user = await this.dynamicDbController(role, username);
if (!user) {
throw new NotFoundException("Actor account not found");
}
if (user.role !== role) {
throw new UnauthorizedException("user not assigned to this role");
}
if (!(await this.hashService.compare(pass, user.password))) {
throw new UnauthorizedException("password is incorrect or access Denied");
}
return user;
}
/**
* Authenticates an actor from a login request body (role, username/email, password).
*/
async loginFromCredentials(body: Record<string, unknown>) {
const role = this.parseActorLoginRole(body?.role);
const username = this.parseActorLoginUsername(body);
const password = body?.password;
if (typeof password !== "string" || !password) {
throw new BadRequestException("password is required");
}
const captchaId =
typeof body?.captchaId === "string" ? body.captchaId : undefined;
const captcha =
typeof body?.captcha === "string" ? body.captcha : undefined;
await this.captchaChallengeService.verify(captchaId, captcha);
const actor = await this.validateActor(username, password, role);
return this.issueActorTokens(actor);
}
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
async loginActors(user: any) {
if (user?.access_token && user?.sub) {
return user;
}
return this.loginFromCredentials(user as Record<string, unknown>);
} }
async registerActors( async registerActors(
@@ -164,7 +241,7 @@ export class ActorAuthService {
firstName: body.firstName, firstName: body.firstName,
lastName: body.lastName, lastName: body.lastName,
phone: body.phone, phone: body.phone,
mobile:body.mobile, mobile: body.mobile,
city: body.city, city: body.city,
state: body.state, state: body.state,
address: body.address, address: body.address,
@@ -273,6 +350,10 @@ export class ActorAuthService {
actor = await this.fieldExpertDbService.findOne(filter); actor = await this.fieldExpertDbService.findOne(filter);
dbServiceToUpdate = this.fieldExpertDbService as any; dbServiceToUpdate = this.fieldExpertDbService as any;
} }
if (!actor) {
actor = await this.registrarDbService.findOne(filter);
dbServiceToUpdate = this.registrarDbService as any;
}
if (!actor) { if (!actor) {
throw new NotFoundException("actor not found"); throw new NotFoundException("actor not found");
@@ -309,6 +390,10 @@ export class ActorAuthService {
userExist = await this.fieldExpertDbService.findOne({ email }); userExist = await this.fieldExpertDbService.findOne({ email });
dbServiceToUpdate = this.fieldExpertDbService; dbServiceToUpdate = this.fieldExpertDbService;
} }
if (!userExist) {
userExist = await this.registrarDbService.findOne({ email });
dbServiceToUpdate = this.registrarDbService as any;
}
if (!userExist) throw new NotFoundException("user not found"); if (!userExist) throw new NotFoundException("user not found");
const decodeOtp = await this.hashService.compare(otp, userExist.otp); const decodeOtp = await this.hashService.compare(otp, userExist.otp);
if (!decodeOtp) throw new UnauthorizedException("otp invalid"); if (!decodeOtp) throw new UnauthorizedException("otp invalid");
@@ -376,13 +461,8 @@ export class ActorAuthService {
"state", "state",
"address", "address",
], ],
field_expert: [ field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
"firstName", registrar: ["email"],
"lastName",
"email",
"phone",
"mobile",
],
}; };
const allowedFields = allowedFieldsByRole[role]; const allowedFields = allowedFieldsByRole[role];
@@ -407,7 +487,11 @@ export class ActorAuthService {
} }
// fetch user detail (document or plain object) // fetch user detail (document or plain object)
const document = await this.dynamicDbController(role, currentUser.role === "company" ? currentUser.username : null, userId); const document = await this.dynamicDbController(
role,
currentUser.role === "company" ? currentUser.username : null,
userId,
);
if (!document) throw new NotFoundException("Profile not found"); if (!document) throw new NotFoundException("Profile not found");
@@ -498,4 +582,34 @@ export class ActorAuthService {
throw er; 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 { import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
BadRequestException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotAcceptableException,
NotFoundException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Types } from "mongoose"; import { Types } from "mongoose";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import {
buildUserLookupByPhone,
normalizeIranMobile,
} from "src/helpers/iran-mobile";
import {
computeOtpExpireMs,
FAKE_OTP_CODE,
isFakeOtpEnabled,
isOtpExpiryActive,
readOtpExpireMinutesFromEnv,
} from "src/helpers/user-otp-expiry";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { HashService } from "src/utils/hash/hash.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() @Injectable()
export class UserAuthService { export class UserAuthService {
private readonly logger = new Logger(UserAuthService.name); private readonly logger = new Logger(UserAuthService.name);
@@ -24,18 +36,32 @@ export class UserAuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpService, private readonly otpCreator: OtpGeneratorService,
private readonly smsManagerService: SmsManagerService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userLinkAccessService: UserLinkAccessService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<any> { async validateUser(
const user = await this.userDbService.findOne({ username }); username: string,
if (!user) throw new NotFoundException("user not found"); pass: string,
binding: LinkBinding = {},
): Promise<any> {
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
const now = new Date().getTime(); await this.userLinkAccessService.assertMobileAllowed({
if (user.otp == null) throw new NotAcceptableException("please get otp"); mobile: canonicalMobile,
if (user.otpExpire < now) { linkToken: binding.linkToken,
throw new NotAcceptableException("expire otp"); linkContext: binding.linkContext,
});
const user = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
if (!isOtpExpiryActive(user.otpExpire)) {
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
} }
if (await this.hashService.compare(pass, user.otp)) { if (await this.hashService.compare(pass, user.otp)) {
return user; return user;
@@ -44,39 +70,59 @@ export class UserAuthService {
} }
async login(user: any) { async login(user: any) {
const userId = String(user._id ?? user.id ?? "");
const payload = { const payload = {
username: user.username, username: user.username,
sub: user.id, sub: userId,
role: "user", role: "user",
}; };
const accToken = this.jwtService.sign(payload, { const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
await this.userDbService.findOneAndUpdate( await this.userDbService.findOneAndUpdate(
{ username: user.username }, { username: user.username },
{ {
tokens: { token: accToken }, tokens: { token: accToken },
otp: null, otp: null,
otpExpire: 0,
}, },
); );
return { return {
userId: user._id, userId,
access_token: accToken, access_token: accToken,
}; };
} }
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> { async sendOtpRequest(
const userExist = await this.userDbService.findOne({ mobile: string,
mobile, binding: LinkBinding = {},
): Promise<LoginDtoRs> {
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
if (!canonicalMobile) {
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
}
await this.userLinkAccessService.assertMobileAllowed({
mobile: canonicalMobile,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
}); });
const otp = this.otpCreator.create();
const userExist = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
const otp = this.createOtpForRequest();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const expireMinutes = readOtpExpireMinutesFromEnv();
const nowMs = Date.now();
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, canonicalMobile);
/// create otp request // console.log(`OTP for ${canonicalMobile}: ${otp}`);
const newUser = await this.userDbService.createUser({ const newUser = await this.userDbService.createUser({
mobile, mobile: canonicalMobile,
username: mobile, username: canonicalMobile,
otp: hashOtp, otp: hashOtp,
tokens: { tokens: {
token: "", token: "",
@@ -90,50 +136,57 @@ export class UserAuthService {
city: "", city: "",
address: "", address: "",
state: "", state: "",
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}); });
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish"); if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
await this.smsSender(otp, mobile); // throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
const updateTokens = await this.userDbService.findOneAndUpdate( return new LoginDtoRs(userExist, "OTP Still valid");
{
username: userExist.username,
},
{
otp: hashOtp,
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
},
);
if (updateTokens) return new LoginDtoRs(userExist);
} }
await this.smsSender(otp, canonicalMobile);
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
await this.userDbService.findOneAndUpdate(
buildUserLookupByPhone(canonicalMobile),
{
otp: hashOtp,
otpExpire,
mobile: canonicalMobile,
username: userExist.username || canonicalMobile,
},
);
return new LoginDtoRs(userExist);
}
private createOtpForRequest(): string {
if (isFakeOtpEnabled()) {
this.logger.warn(
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
);
return FAKE_OTP_CODE;
}
return this.otpCreator.create();
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
return this.smsManagerService if (isFakeOtpEnabled()) {
.verifyLookUp({ this.logger.log(
token: otp, `FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
template: process.env.AUTH_SMS_TEMPLATE, );
receptor: mobile, return;
}) }
.then((smsRes) => { const ok = await this.smsOrchestrationService.sendAuthOtp(
this.logger.log( mobile,
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `, otp,
); process.env.AUTH_SMS_TEMPLATE,
}) );
.catch((er) => { if (!ok) {
this.logger.error( throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `, }
); this.logger.log(
throw new HttpException( `Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
" auth sms send failed", );
HttpStatus.INTERNAL_SERVER_ERROR,
);
});
} }
} }

View File

@@ -1,42 +1,74 @@
import { Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt"; import { JwtModule, JwtService } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport"; import { MongooseModule } from "@nestjs/mongoose";
import { LocalStrategy } from "src/auth/stratregys/local.strategy"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy"; import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller"; import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller"; import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import {
ClaimCase,
ClaimCaseSchema,
} from "src/claim-request-management/entites/schema/claim-cases.schema";
import {
ClaimRequestManagementModel,
ClaimRequestManagementSchema,
} from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import {
BlameRequest,
BlameRequestSchema,
} from "src/request-management/entities/schema/blame-cases.schema";
import {
RequestManagementModel,
RequestManagementSchema,
} from "src/request-management/entities/schema/request-management.schema";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { OtpModule } from "src/utils/otp/otp.module"; import { CaptchaModule } from "src/captcha/captcha.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
@Global()
@Module({ @Module({
imports: [ imports: [
// MailModule, // Mailer disabled not used
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,
OtpModule, CaptchaModule,
PassportModule, SmsOrchestrationModule,
SmsManagerModule, MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
{ name: BlameRequest.name, schema: BlameRequestSchema },
{
name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema,
},
{ name: ClaimCase.name, schema: ClaimCaseSchema },
]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
global: true, global: true,
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}), }),
], ],
providers: [ providers: [
UserAuthService, UserAuthService,
UserLinkAccessService,
ActorAuthService, ActorAuthService,
LocalStrategy,
LocalActorStrategy,
JwtService, JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
],
exports: [
UserAuthService,
ActorAuthService,
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
], ],
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
controllers: [UserAuthController, ActorAuthController], controllers: [UserAuthController, ActorAuthController],
}) })
export class AuthModule {} 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"; import { RoleEnum } from "src/Types&Enums/role.enum";
export class LoginActorDto { export class LoginActorDto {
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" }) @ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
role: RoleEnum[]; role: RoleEnum[];
@ApiProperty({}) @ApiPropertyOptional({
username: string; description:
"Actor email or username. For field experts you may also send nationalCode instead.",
})
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({
description: "Alias for username when logging in with email.",
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
example: "4311402422",
description:
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
})
@IsOptional()
@IsString()
nationalCode?: string;
@ApiProperty({}) @ApiProperty({})
password: string; password: string;
@ApiProperty({
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
description: "Captcha id from GET /actor/captcha.",
})
@IsString()
@IsNotEmpty()
@MaxLength(64)
captchaId: string;
@ApiProperty({
example: "a7bx2",
description: "Characters shown in the captcha image.",
})
@IsString()
@IsNotEmpty()
@MaxLength(16)
captcha: string;
} }
export class LoginActorDtoRs extends LoginActorDto { export class LoginActorDtoRs extends LoginActorDto {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

@@ -2,56 +2,84 @@ import {
Controller, Controller,
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
BadRequestException,
Param, Param,
Query,
Post, Post,
Patch, Patch,
Put,
Body, Body,
UseGuards, UseGuards,
Get, Get,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
} from "@nestjs/common"; } from "@nestjs/common";
import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "path"; import { extname } from "node:path";
import { Types } from "mongoose";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-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 { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto"; import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto"; import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto"; import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@ApiTags("claim-request-management (v2)") @ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER) @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {} ) {}
@Get("requests") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "Get My Claims (V2)", summary: "Get My Claims (V2)",
description: "Get list of all claim requests for the current user.", description:
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: "List of user claims", description: "List of user claims",
type: GetMyClaimsV2ResponseDto, type: GetMyClaimsV2ResponseDto,
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(
@CurrentUser() user: any,
@Query() query: ListQueryV2Dto,
): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub); return await this.claimRequestManagementService.getMyClaimsV2(
user.sub,
user,
query,
);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -68,7 +96,8 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiOperation({ @ApiOperation({
summary: "Get Claim Details (V2)", 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({ @ApiResponse({
status: 200, status: 200,
@@ -91,6 +120,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2( return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -100,6 +130,208 @@ 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") @Post("create-from-blame/:blameRequestId")
@ApiParam({ @ApiParam({
name: "blameRequestId", name: "blameRequestId",
@@ -127,6 +359,66 @@ export class ClaimRequestManagementV2Controller {
/** /**
* V2 API: Select damaged outer car parts (Step 2 of claim workflow) * 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") @Patch("select-outer-parts/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)", summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
@@ -161,41 +453,35 @@ export class ClaimRequestManagementV2Controller {
}) })
@ApiBody({ @ApiBody({
type: SelectOuterPartsV2Dto, type: SelectOuterPartsV2Dto,
description: "Array of selected damaged outer parts", description:
"Selected vehicle type + selected outer part IDs from catalog",
examples: { examples: {
example1: { example1: {
summary: "Minor front damage", summary: "Sedan - minor front damage",
value: { value: {
selectedParts: ["hood", "front_bumper", "front_right_fender"], carType: "sedan",
selectedPartIds: [19, 21, 16],
}, },
}, },
example2: { example2: {
summary: "Side impact damage", summary: "SUV - left side impact",
value: { value: {
selectedParts: [ carType: "suv",
"front_left_door", selectedPartIds: [102, 103, 104, 107],
"rear_left_door",
"front_left_fender",
"rear_left_fender",
],
}, },
}, },
example3: { example3: {
summary: "Rear-end collision", summary: "Hatchback - rear-end collision",
value: { value: {
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"], carType: "hatchback",
selectedPartIds: [225, 226, 210],
}, },
}, },
example4: { example4: {
summary: "Multiple damage areas", summary: "Pickup - two sides + roof",
value: { value: {
selectedParts: [ carType: "pickup",
"hood", selectedPartIds: [319, 312, 330],
"front_bumper",
"front_right_door",
"rear_bumper",
"trunk",
],
}, },
}, },
}, },
@@ -231,6 +517,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -252,13 +539,14 @@ export class ClaimRequestManagementV2Controller {
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim) **Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** User selects non-body damaged parts and provides bank information for payment. **Purpose:** User selects non-body damaged parts and provides bank information for payment.
Optional: upload car green card file in the same step.
**Validations:** **Validations:**
- Claim must exist - Claim must exist
- User must be the claim owner (damaged party from blame case) - User must be the claim owner (damaged party from blame case)
- Current workflow step must be SELECT_OTHER_PARTS - Current workflow step must be SELECT_OTHER_PARTS
- Bank information must not have been submitted previously - 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 - National code must be exactly 10 digits
**Valid Other Parts (Optional):** **Valid Other Parts (Optional):**
@@ -267,8 +555,8 @@ export class ClaimRequestManagementV2Controller {
- headlight, taillight, mirror, glass - headlight, taillight, mirror, glass
**After Success:** **After Success:**
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4) - Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
- User can proceed to upload required documents - User captures car angles and damaged-part photos, then uploads required documents (Step 5)
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -276,42 +564,38 @@ export class ClaimRequestManagementV2Controller {
description: "The claim case ID (MongoDB ObjectId)", description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011", 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({ @ApiBody({
type: SelectOtherPartsV2Dto, description:
description: "Other parts selection and bank information", "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
examples: { schema: {
example1: { type: "object",
summary: "Only bank info (no other parts damaged)", properties: {
value: { otherParts: {
otherParts: [], oneOf: [
shebaNumber: "123456789012345678901234", { type: "array", items: { type: "string" } },
nationalCodeOfOwner: "1234567890", { type: "string", description: "JSON string array for multipart" },
}, ],
}, example: ["engine", "suspension"],
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",
}, },
sheba: { type: "string", example: "IR123456789012345678901234" },
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
file: { type: "string", format: "binary" },
}, },
required: ["sheba", "nationalCodeOfInsurer"],
}, },
}) })
@ApiResponse({ @ApiResponse({
@@ -339,12 +623,17 @@ export class ClaimRequestManagementV2Controller {
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto, @Body() body: SelectOtherPartsV2Dto,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
// Green-card photo is optional here — the helper no-ops on missing file.
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.selectOtherPartsV2( return await this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
file,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -364,11 +653,13 @@ export class ClaimRequestManagementV2Controller {
summary: "Get Capture Requirements (V2)", summary: "Get Capture Requirements (V2)",
description: ` description: `
**Get list of what needs to be captured:** **Get list of what needs to be captured:**
- Required documents (13 items) - Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
- Car angles (4 items: front, back, left, right) - Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). Returns status of each item (uploaded/captured or not).
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -397,6 +688,7 @@ Returns status of each item (uploaded/captured or not).
return await this.claimRequestManagementService.getCaptureRequirementsV2( return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -415,7 +707,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-documents", destination: "./files/claim-documents",
@@ -430,20 +722,19 @@ Returns status of each item (uploaded/captured or not).
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Upload Required Document (V2 - Step 4)", summary: "Upload Required Document (V2 - Step 5)",
description: ` 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:** **Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card
- damaged_driving_license_front/back - damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo - damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate - damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back - guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
- guilty_car_card_front/back, guilty_metal_plate
**When all 13 documents are uploaded:** **When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
**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({ @ApiParam({
@@ -459,7 +750,6 @@ Returns status of each item (uploaded/captured or not).
documentKey: { documentKey: {
type: "string", type: "string",
enum: [ enum: [
"car_green_card",
"damaged_driving_license_front", "damaged_driving_license_front",
"damaged_driving_license_back", "damaged_driving_license_back",
"damaged_chassis_number", "damaged_chassis_number",
@@ -473,7 +763,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_back", "guilty_car_card_back",
"guilty_metal_plate", "guilty_metal_plate",
], ],
example: "car_green_card", example: "damaged_driving_license_front",
}, },
file: { file: {
type: "string", type: "string",
@@ -501,12 +791,14 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.uploadRequiredDocumentV2( return await this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId, claimRequestId,
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -523,7 +815,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-captures", destination: "./files/claim-captures",
@@ -538,15 +830,17 @@ Returns status of each item (uploaded/captured or not).
) )
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)", summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
description: ` description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim) **Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
**Capture types:** **Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required 1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2 2. **part**: Damaged parts based on selectedParts from Step 2
**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({ @ApiParam({
@@ -592,12 +886,14 @@ Returns status of each item (uploaded/captured or not).
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any, @CurrentUser() user: any,
): Promise<CapturePartV2ResponseDto> { ): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
try { try {
return await this.claimRequestManagementService.capturePartV2( return await this.claimRequestManagementService.capturePartV2(
claimRequestId, claimRequestId,
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -606,4 +902,132 @@ 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,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
/** /**
* V2 DTO for capturing car angle or damaged part * V2 DTO for capturing car angle or damaged part
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
captureType: 'angle' | 'part'; captureType: 'angle' | 'part';
@ApiProperty({ @ApiProperty({
description: 'Key of the angle or part being captured', description:
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
example: 'front', example: 'front',
}) })
@IsNotEmpty({ message: 'Capture key is required' }) @IsNotEmpty({ message: 'Capture key is required' })
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
example: 'Angle captured successfully. 6 captures remaining.', example: 'Angle captured successfully. 6 captures remaining.',
}) })
message: string; message: string;
@ApiPropertyOptional({
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
})
expertResendComplete?: boolean;
}
/**
* 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 * DTO for required document item
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'], enum: ['general', 'damaged_party', 'guilty_party'],
}) })
category: string; category: string;
@ApiPropertyOptional({
description:
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
example: true,
})
preferUploadDuringCapture?: boolean;
} }
/** /**
@@ -71,10 +79,17 @@ export class CarAngleItem {
*/ */
export class DamagedPartItem { export class DamagedPartItem {
@ApiProperty({ @ApiProperty({
description: 'Part key', description:
example: 'hood', 'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
example: 'backfender',
}) })
key: string; name: string;
@ApiPropertyOptional({
description: 'Vehicle side / region (left, right, front, back, top)',
example: 'left',
})
side?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in Farsi', description: 'Display label in Farsi',
@@ -82,6 +97,12 @@ export class DamagedPartItem {
}) })
label_fa: string; label_fa: string;
@ApiPropertyOptional({
description: 'Deprecated: same as `name` (kept for older clients)',
example: 'backfender',
})
key?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in English', description: 'Display label in English',
example: 'Hood', example: 'Hood',
@@ -93,6 +114,12 @@ export class DamagedPartItem {
example: false, example: false,
}) })
captured: boolean; captured: boolean;
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
@ApiPropertyOptional({ example: 12 })
@IsOptional()
@IsInt()
id?: number;
} }
/** /**
@@ -117,6 +144,20 @@ export class GetCaptureRequirementsV2ResponseDto {
}) })
currentStep: string; currentStep: string;
@ApiProperty({
description:
'Ordered capture phase during CAPTURE_PART_DAMAGES: parts → angles → capture_phase_documents',
example: 'angles',
enum: ['parts', 'angles', 'capture_phase_documents', 'complete'],
})
captureSequencePhase: string;
@ApiProperty({
description: 'Human-readable hint for what the user should do next in the capture step',
example: 'Capture all four car angles (front, back, left, right) next.',
})
captureSequenceHint: string;
@ApiProperty({ @ApiProperty({
description: 'List of required documents to upload', description: 'List of required documents to upload',
type: [RequiredDocumentItem], type: [RequiredDocumentItem],
@@ -159,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
anglesTotal: number; anglesTotal: number;
partsCaptured: number; partsCaptured: number;
partsTotal: number; partsTotal: number;
capturePhaseDocsRemaining: number;
postCaptureDocumentsRemaining: number;
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
otherParts?: OtherCarPart[]; otherParts?: OtherCarPart[];
@ApiProperty({ @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', example: '123456789012345678901234',
pattern: '^[0-9]{24}$',
minLength: 24,
maxLength: 24,
}) })
@IsNotEmpty({ message: 'Sheba number is required' }) @IsOptional()
@IsString({ message: 'Sheba number must be a string' }) @IsString()
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' }) shebaNumber?: string;
@Matches(/^[0-9]{24}$/, {
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
})
shebaNumber: string;
@ApiProperty({ @ApiProperty({
description: 'National code of the owner - 10 digits', description: 'National code of insurer/owner - 10 digits',
example: '1234567890', example: '1234567890',
pattern: '^[0-9]{10}$', pattern: '^[0-9]{10}$',
minLength: 10, minLength: 10,
maxLength: 10, maxLength: 10,
}) })
@IsNotEmpty({ message: 'National code of owner is required' }) @IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsString({ message: 'National code must be a string' }) @IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' }) @Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, { @Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits', 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({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS', example: 'CAPTURE_PART_DAMAGES',
}) })
currentStep: string; currentStep: string;
@ApiProperty({ @ApiProperty({
description: 'Next possible workflow step', description: 'Next possible workflow step',
example: 'CAPTURE_PART_DAMAGES', example: 'UPLOAD_REQUIRED_DOCUMENTS',
}) })
nextStep: string; nextStep: string;
@ApiProperty({ @ApiProperty({
description: 'Success message', 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; 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,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator'; 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 * Enum for valid outer car parts that can be damaged
@@ -43,7 +48,7 @@ export class SelectOuterPartsV2Dto {
minItems: 1, minItems: 1,
maxItems: 13, maxItems: 13,
}) })
@IsNotEmpty({ message: 'Selected parts array cannot be empty' }) @IsOptional()
@IsArray({ message: 'selectedParts must be an array' }) @IsArray({ message: 'selectedParts must be an array' })
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' }) @ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
@ArrayUnique({ message: 'Duplicate parts are not allowed' }) @ArrayUnique({ message: 'Duplicate parts are not allowed' })
@@ -51,7 +56,29 @@ export class SelectOuterPartsV2Dto {
each: true, each: true,
message: 'Invalid part name. Must be one of the valid outer car parts', message: 'Invalid part name. Must be one of the valid outer car parts',
}) })
selectedParts: OuterCarPart[]; selectedParts?: OuterCarPart[];
@ApiProperty({
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;
} }
/** /**
@@ -71,10 +98,18 @@ export class SelectOuterPartsV2ResponseDto {
publicId: string; publicId: string;
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged parts', description:
example: ['hood', 'front_right_door', 'rear_bumper'], "Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
type: [DamageSelectedPartV2BodyDto],
}) })
selectedParts: string[]; selectedParts: DamageSelectedPartV2BodyDto[];
@ApiProperty({
description: 'Selected part IDs',
example: [9, 7, 11],
type: [Number],
})
selectedPartIds: number[];
@ApiProperty({ @ApiProperty({
description: 'Current workflow step', description: 'Current workflow step',
@@ -94,3 +129,65 @@ export class SelectOuterPartsV2ResponseDto {
}) })
message: string; message: string;
} }
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,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
example: 'Document uploaded successfully. 12 documents remaining.', example: 'Document uploaded successfully. 12 documents remaining.',
}) })
message: string; 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;
} }

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

View File

@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true }); 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( async find(
filter: FilterQuery<ClaimCase>, filter: FilterQuery<ClaimCase>,
options?: { lean?: boolean; select?: string }, options?: { lean?: boolean; select?: string },

View File

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

View File

@@ -1,15 +1,24 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "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 { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum"; import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimPartPricing { export class ClaimPartPricing {
@Prop({ type: String }) @Prop({ type: Number })
partId: string; 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 }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@@ -23,8 +32,9 @@ export class ClaimPartPricing {
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
totalPayment?: string | number; totalPayment?: string | number;
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
daghi?: string | number; daghi?: string | number | Record<string, unknown>;
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
factorNeeded?: boolean; factorNeeded?: boolean;
@@ -67,6 +77,43 @@ export class ClaimUserComment {
export const ClaimUserCommentSchema = export const ClaimUserCommentSchema =
SchemaFactory.createForClass(ClaimUserComment); SchemaFactory.createForClass(ClaimUserComment);
/** Owner acceptance + signature after expert pricing (post-expert insurer `INSURER_REVIEW` — `INSURER_REVIEW_AWAITING_OWNER_SIGN` / `INSURER_REVIEW_MIXED_FACTORS_PENDING` / legacy `WAITING_FOR_INSURER_APPROVAL`). */
@Schema({ _id: false })
export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerInsurerApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
@Schema({ _id: false })
export class ClaimOwnerPricedPartsApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerPricedPartsApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimExpertReply { export class ClaimExpertReply {
@Prop({ type: String }) @Prop({ type: String })
@@ -83,14 +130,19 @@ export class ClaimExpertReply {
@Prop({ type: ClaimUserCommentSchema }) @Prop({ type: ClaimUserCommentSchema })
userComment?: ClaimUserComment; userComment?: ClaimUserComment;
/** Damage expert profile at reply time (from `damage-expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ClaimExpertReplySchema = export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply); SchemaFactory.createForClass(ClaimExpertReply);
/** One line item the user disputes on an expert-priced part */
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimUserObjection { export class ClaimUserObjectionPart {
@Prop({ type: String, required: true }) @Prop({ type: Number, required: true })
partId: string; partId: number;
@Prop({ type: String }) @Prop({ type: String })
reason?: string; reason?: string;
@@ -103,9 +155,48 @@ export class ClaimUserObjection {
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
side?: string;
} }
export const ClaimUserObjectionSchema = export const ClaimUserObjectionPartSchema =
SchemaFactory.createForClass(ClaimUserObjection); 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 }) @Schema({ _id: false })
export class ClaimResendRequest { export class ClaimResendRequest {
@@ -117,6 +208,17 @@ export class ClaimResendRequest {
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) @Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendCarParts?: any[]; 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 = export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest); SchemaFactory.createForClass(ClaimResendRequest);
@@ -145,6 +247,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 }) @Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number; guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
} }
export const ClaimFileRatingSchema = export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating); SchemaFactory.createForClass(ClaimFileRating);
@@ -165,6 +270,12 @@ export class ClaimPriceDrop {
@Prop({ type: Number }) @Prop({ type: Number })
sumOfSeverity?: number; sumOfSeverity?: number;
@Prop({ type: Number })
coefficientYear?: number;
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
partLines?: Array<Record<string, unknown>>;
} }
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop); export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
@@ -182,8 +293,8 @@ export class ClaimEvaluation {
@Prop({ type: ClaimResendRequestSchema }) @Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null; damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema }) @Prop({ type: ClaimUserObjectionPayloadSchema })
objection?: ClaimUserObjection; objection?: ClaimUserObjectionPayload;
@Prop({ type: ClaimUserResendPayloadSchema }) @Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload; userResendDocuments?: ClaimUserResendPayload;
@@ -191,11 +302,25 @@ export class ClaimEvaluation {
@Prop({ type: ClaimFileRatingSchema }) @Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating; rating?: ClaimFileRating;
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
@Prop({ type: String }) @Prop({ type: String })
visitLocation?: string; visitLocation?: string;
@Prop({ type: ClaimPriceDropSchema }) @Prop({ type: ClaimPriceDropSchema })
priceDrop?: ClaimPriceDrop; 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 = export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation); SchemaFactory.createForClass(ClaimEvaluation);

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; 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 }) @Schema({ _id: false })
export class ClaimActorLock { export class ClaimActorLock {
@@ -12,9 +17,29 @@ export class ClaimActorLock {
@Prop({ type: String, default: "damage_expert" }) @Prop({ type: String, default: "damage_expert" })
actorRole: "damage_expert" | "expert" | "admin"; 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); 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 }) @Schema({ _id: false })
export class ClaimWorkflow { export class ClaimWorkflow {
@Prop({ type: String, enum: ClaimWorkflowStep }) @Prop({ type: String, enum: ClaimWorkflowStep })
@@ -32,8 +57,22 @@ export class ClaimWorkflow {
@Prop({ type: Date }) @Prop({ type: Date })
lockedAt?: Date; lockedAt?: Date;
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
@Prop({ type: Date })
expiredAt?: Date;
@Prop({ type: ClaimActorLockSchema }) @Prop({ type: ClaimActorLockSchema })
lockedBy?: ClaimActorLock; lockedBy?: ClaimActorLock;
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
/**
* First damage expert who called review assign; kept after the 15m lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ClaimActorLockSchema })
assignedForReviewBy?: ClaimActorLock;
} }
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type"; import {
HistoryEvent,
HistoryEventSchema,
} from "src/request-management/entities/schema/historyEvent.type";
import { import {
ClaimDamageSelection, ClaimDamageSelection,
ClaimDamageSelectionSchema, ClaimDamageSelectionSchema,
@@ -25,7 +28,15 @@ import {
ClaimCaseSnapshot, ClaimCaseSnapshot,
ClaimCaseSnapshotSchema, ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema"; } from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema"; import {
ClaimWorkflow,
ClaimWorkflowSchema,
} from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema";
import {
CaseInquiries,
CaseInquiriesSchema,
} from "src/common/schema/case-inquiries.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class RequiredDocumentRef { export class RequiredDocumentRef {
@@ -44,7 +55,8 @@ export class RequiredDocumentRef {
@Prop({ type: Date }) @Prop({ type: Date })
uploadedAt?: Date; uploadedAt?: Date;
} }
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(RequiredDocumentRef); export const RequiredDocumentRefSchema =
SchemaFactory.createForClass(RequiredDocumentRef);
@Schema({ @Schema({
collection: "claimCases", collection: "claimCases",
@@ -66,7 +78,12 @@ export class ClaimCase {
/** /**
* Overall case status (user flow + expert flow progression) * Overall case status (user flow + expert flow progression)
*/ */
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED }) @Prop({
required: true,
type: String,
enum: ClaimCaseStatus,
default: ClaimCaseStatus.CREATED,
})
status: ClaimCaseStatus; status: ClaimCaseStatus;
/** /**
@@ -75,6 +92,12 @@ export class ClaimCase {
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING }) @Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus; 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. * Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file). * IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
@@ -85,6 +108,22 @@ export class ClaimCase {
@Prop({ type: String, index: true }) @Prop({ type: String, index: true })
blameRequestNo?: string; blameRequestNo?: string;
/**
* Set when this claim was created by a field expert on behalf of the damaged
* party (expert-initiated IN_PERSON flow). Used to scope the expert's panel
* access to only their own initiated files.
*/
@Prop({ type: Types.ObjectId, index: true })
initiatedByFieldExpertId?: Types.ObjectId;
/**
* The damaged party's userId, resolved from the blame at claim-creation time.
* Stored here so view/list access does not require an extra blame lookup.
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
*/
@Prop({ type: Types.ObjectId, index: true })
damagedPartyUserId?: Types.ObjectId;
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) }) @Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
workflow?: ClaimWorkflow; workflow?: ClaimWorkflow;
@@ -112,6 +151,9 @@ export class ClaimCase {
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) }) @Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
evaluation?: ClaimEvaluation; evaluation?: ClaimEvaluation;
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
inquiries?: CaseInquiries;
/** /**
* Optional “read-optimized” copy of fields from blame/request side. * Optional “read-optimized” copy of fields from blame/request side.
* Source of truth remains `blameRequestId`. * Source of truth remains `blameRequestId`.
@@ -133,6 +175,15 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] }) @Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[]; 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. * Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these. * If you choose to migrate later, we can remove these.
@@ -143,5 +194,4 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>; export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

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

View File

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

View File

@@ -0,0 +1,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,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 { import {
Body, ApiBody,
Controller, ApiOperation,
Get, ApiResponse,
Post, ApiTags,
UseGuards, } from "@nestjs/swagger";
} from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { CurrentUser } from "src/decorators/user.decorator"; 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 { ClientService } from "./client.service";
import { ClientDto } from "./dto/create-client.dto"; import { ClientDto } from "./dto/create-client.dto";
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
@Controller("client") @Controller("client")
@ApiTags("client-management") @ApiTags("client-management")
export class ClientController { export class ClientController {
constructor(private readonly clientService: ClientService) {} constructor(
private readonly clientService: ClientService,
private readonly systemSettingsService: SystemSettingsService,
) {}
@Post() @Post()
@UseGuards(GlobalGuard)
@ApiBearerAuth()
async addClient(@Body() client: ClientDto) { async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client); return await this.clientService.addClient(client);
} }
@Get() @Get()
@ApiBearerAuth()
@UseGuards(GlobalGuard)
async getClient(@CurrentUser() user) { async getClient(@CurrentUser() user) {
return await this.clientService.getClients(); return await this.clientService.getClients();
} }
@@ -34,4 +34,33 @@ export class ClientController {
async getClientList(@CurrentUser() user) { async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList(); return await this.clientService.getClientList();
} }
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live")
@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 { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { ClientController } from "./client.controller"; import { ClientController } from "./client.controller";
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
import { ClientPanelController } from "./client-panel.controller";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
import { BranchDbService } from "./entities/db-service/branch.db.service"; import { BranchDbService } from "./entities/db-service/branch.db.service";
import { ClientDbService } from "./entities/db-service/client.db.service"; import { ClientDbService } from "./entities/db-service/client.db.service";
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema"; import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
@@ -9,6 +13,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
@Module({ @Module({
imports: [ imports: [
SystemSettingsModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: ClientModel.name, schema: ClientDbSchema }, { name: ClientModel.name, schema: ClientDbSchema },
{ {
@@ -17,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
}, },
]), ]),
], ],
controllers: [ClientController], controllers: [
providers: [ClientService, ClientDbService, BranchDbService], ClientController,
exports: [ClientService, ClientDbService, BranchDbService], ClientPanelController,
ClientExternalInquiriesController,
],
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
}) })
export class ClientModule {} export class ClientModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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