Compare commits

...

79 Commits

Author SHA1 Message Date
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
100 changed files with 11107 additions and 3614 deletions

2895
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@nestjs-modules/mailer": "^1.8.1", "@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3", "@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
@@ -44,6 +45,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2", "form-data": "^4.0.2",

View File

@@ -3,6 +3,9 @@ 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

@@ -15,5 +15,7 @@ export enum CaseStatus {
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED" AUTO_CLOSED = "AUTO_CLOSED",
STOPPED = "STOPPED"
} }

View File

@@ -0,0 +1,41 @@
import { ResendItemType } from "./resendItemType.enum";
/** 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.VIDEO) return "video";
if (item === ResendItemType.DESCRIPTION) return "text";
return "document_camera";
}
export function buildResendItemsWithUi(requestedItems: string[]) {
return requestedItems.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,12 @@ export enum ResendItemType {
CAR_CERTIFICATE = "carCertificate", CAR_CERTIFICATE = "carCertificate",
DRIVING_LICENSE = "drivingLicense", DRIVING_LICENSE = "drivingLicense",
CAR_GREEN_CARD = "carGreenCard", CAR_GREEN_CARD = "carGreenCard",
PLATE = "plate",
CAR_PLATE = "carPlate",
// Media evidence // Media evidence
VOICE = "voice", VOICE = "voice",
VIDEO = "video", VIDEO = "video",
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
DESCRIPTION = "description",
} }

View File

@@ -11,6 +11,9 @@ 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",

View File

@@ -7,17 +7,22 @@ export enum ClaimWorkflowStep {
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",
// Insurer approval // Insurer approval

View File

@@ -2,6 +2,9 @@ export enum ClaimRequiredDocumentType {
// Car green card // Car green card
CAR_GREEN_CARD = "car_green_card", CAR_GREEN_CARD = "car_green_card",
/** 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",
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front", DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",

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

@@ -19,6 +19,7 @@ import { SandHubModule } from "./sand-hub/sand-hub.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";
@@ -44,6 +45,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
authMechanism: "SCRAM-SHA-256", authMechanism: "SCRAM-SHA-256",
tls: true, tls: true,
tlsAllowInvalidCertificates: true, tlsAllowInvalidCertificates: true,
connectionFactory: (connection) => {
applyIranFaTimestampPlugin(connection);
return connection;
},
}, },
), ),
UsersModule, UsersModule,

View File

@@ -23,6 +23,7 @@ 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,
@@ -64,6 +65,14 @@ 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()

View File

@@ -25,6 +25,7 @@ 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 { MailService } from "src/utils/mail/mail.service";
import { OtpService } from "src/utils/otp/otp.service"; import { OtpService } from "src/utils/otp/otp.service";
@@ -46,6 +47,7 @@ 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 mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
@@ -79,6 +81,13 @@ export class ActorAuthService {
else else
res = await this.fieldExpertDbService.findOne({ email: username }); res = await this.fieldExpertDbService.findOne({ email: username });
break; break;
case RoleEnum.REGISTRAR:
if (username == null && userId)
res = await this.registrarDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.registrarDbService.findOne({ email: username });
break;
case RoleEnum.COMPANY: case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username }); res = await this.insurerExpertDbService.findOne({ email: username });
break; break;
@@ -273,6 +282,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 +322,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");
@@ -383,6 +400,7 @@ export class ActorAuthService {
"phone", "phone",
"mobile", "mobile",
], ],
registrar: ["email"],
}; };
const allowedFields = allowedFieldsByRole[role]; const allowedFields = allowedFieldsByRole[role];
@@ -498,4 +516,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

@@ -13,7 +13,7 @@ import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service"; import { OtpService } from "src/utils/otp/otp.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
@@ -25,7 +25,7 @@ export class UserAuthService {
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpService, private readonly otpCreator: OtpService,
private readonly smsManagerService: SmsManagerService, private readonly smsOrchestrationService: SmsOrchestrationService,
) {} ) {}
async validateUser(username: string, pass: string): Promise<any> { async validateUser(username: string, pass: string): Promise<any> {
@@ -71,6 +71,12 @@ export class UserAuthService {
}); });
const otp = this.otpCreator.create(); const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, mobile);
/// create otp request /// create otp request
@@ -90,9 +96,7 @@ 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);
} }
@@ -105,9 +109,7 @@ export class UserAuthService {
}, },
{ {
otp: hashOtp, otp: hashOtp,
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}, },
); );
if (updateTokens) return new LoginDtoRs(userExist); if (updateTokens) return new LoginDtoRs(userExist);
@@ -115,25 +117,19 @@ export class UserAuthService {
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
return this.smsManagerService const ok = await this.smsOrchestrationService.sendAuthOtp(
.verifyLookUp({ mobile,
token: otp, otp,
template: process.env.AUTH_SMS_TEMPLATE, process.env.AUTH_SMS_TEMPLATE,
receptor: mobile,
})
.then((smsRes) => {
this.logger.log(
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
);
})
.catch((er) => {
this.logger.error(
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
); );
if (!ok) {
throw new HttpException( throw new HttpException(
"auth sms send failed", "auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.BAD_GATEWAY,
);
}
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
); );
});
} }
} }

View File

@@ -12,7 +12,7 @@ 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 { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module"; import { OtpModule } from "src/utils/otp/otp.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({ @Module({
imports: [ imports: [
@@ -22,7 +22,7 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
HashModule, HashModule,
OtpModule, OtpModule,
PassportModule, PassportModule,
SmsManagerModule, SmsOrchestrationModule,
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" },
global: true, global: true,

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

@@ -44,6 +44,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");

View File

@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
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();
} }
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.SECRET}`,
}); });
if (payload.role !== RoleEnum.USER) {
console.log( if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
request.user.role,
);
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
request.user = payload; request.user = payload;
request.identity = request.user; request.identity = request.user;
} catch { } catch {

View File

@@ -6,6 +6,7 @@ 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 { 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";
@@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimAccessGuard, ClaimAccessGuard,
], ],
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller], controllers: [
ClaimRequestManagementController,
ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller,
],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,
ClaimRequestManagementDbService, ClaimRequestManagementDbService,

View File

@@ -5,6 +5,7 @@ import {
Param, Param,
Post, Post,
Patch, Patch,
Put,
Body, Body,
UseGuards, UseGuards,
Get, Get,
@@ -25,15 +26,21 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
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 { 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";
@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)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -51,7 +58,7 @@ export class ClaimRequestManagementV2Controller {
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub); return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -68,7 +75,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:
"Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -91,6 +99,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 +109,120 @@ 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:
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
})
@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",
);
}
}
@Post("create-from-blame/:blameRequestId") @Post("create-from-blame/:blameRequestId")
@ApiParam({ @ApiParam({
name: "blameRequestId", name: "blameRequestId",
@@ -231,6 +354,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 +376,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 +392,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 +401,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: 10 * 1024 * 1024 },
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 +460,15 @@ 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> {
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;
@@ -369,6 +493,8 @@ export class ClaimRequestManagementV2Controller {
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). Returns status of each item (uploaded/captured or not).
**V2 order:** Complete angles and part photos first, then required documents.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -397,6 +523,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;
@@ -430,20 +557,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 +585,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 +598,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",
@@ -507,6 +632,7 @@ Returns status of each item (uploaded/captured or not).
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;
@@ -538,15 +664,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:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
**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({
@@ -598,6 +726,7 @@ Returns status of each item (uploaded/captured or not).
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 +735,129 @@ 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 factor file for a priced part (V2)",
description:
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
})
@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: 10 * 1024 * 1024 },
}),
)
async uploadFactorForPartV2(
@Param("claimRequestId") claimRequestId: string,
@Param("partId") partId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any,
) {
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: 50 * 1024 * 1024 },
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> {
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,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 { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
@@ -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,5 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** Active damage-expert resend request (owner must upload or acknowledge). */
export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional()
resendDescription?: string;
@ApiPropertyOptional({ type: [String] })
resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] })
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: 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' })
claimRequestId: string; claimRequestId: string;
@@ -54,8 +69,8 @@ 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 }>;
@@ -63,6 +78,21 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>; damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
})
expertResend?: ExpertResendDetailsV2Dto;
@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

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

@@ -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,24 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsOptional, ValidateNested } from "class-validator";
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/**
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
* with nested validation enabled for the v2 controller pipeline.
*/
export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto)
newParts?: NewPartDto[];
}

View File

@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true }); 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,5 +1,9 @@
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";
@@ -8,8 +12,12 @@ export class ClaimPartPricing {
@Prop({ type: String }) @Prop({ type: String })
partId: string; partId: string;
@Prop({ type: String }) /**
carPartDamage?: string; * Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2).
* Must be Mixed — objects cannot cast to String.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carPartDamage?: string | { part?: string; side?: string };
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;
@@ -23,8 +31,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;
@@ -83,12 +92,17 @@ 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: String, required: true })
partId: string; partId: string;
@@ -103,9 +117,47 @@ 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 {
@Prop({ type: String })
partId?: 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 +169,14 @@ 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;
} }
export const ClaimResendRequestSchema = export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest); SchemaFactory.createForClass(ClaimResendRequest);
@@ -182,8 +242,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;
@@ -196,6 +256,14 @@ export class ClaimEvaluation {
@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,6 +1,10 @@
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 {
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,6 +16,10 @@ 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);

View File

@@ -26,6 +26,7 @@ import {
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";
@Schema({ _id: false }) @Schema({ _id: false })
export class RequiredDocumentRef { export class RequiredDocumentRef {
@@ -75,6 +76,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).
@@ -133,6 +140,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.

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

@@ -0,0 +1,135 @@
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, ApiBody, ApiConsumes, ApiOperation, ApiParam, 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 { 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";
@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: 10 * 1024 * 1024 },
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: 10 * 1024 * 1024 },
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

@@ -35,9 +35,14 @@ export class ResendRequestDto {
description: "Array of resend requests (can include first party, second party, or both)", description: "Array of resend requests (can include first party, second party, or both)",
example: [ example: [
{ {
partyId: "507f1f77bcf86cd799439011", partyId: "681b3b3fb237e5856429e444",
requestedItems: ["drivingLicense", "carCertificate"], requestedItems: ["drivingLicense"],
description: "Please resend with better quality", description: "DESC TEST1",
},
{
partyId: "68b6ba552a6e897df34e0f90",
requestedItems: ["carCertificate"],
description: "DESC TEST2",
}, },
], ],
}) })

View File

@@ -6,9 +6,16 @@ import { ExpertBlameController } from "./expert-blame.controller";
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller"; import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({ @Module({
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule], imports: [
UsersModule,
ClientModule,
RequestManagementModule,
PlatesModule,
SmsOrchestrationModule,
],
controllers: [ExpertBlameController, ExpertBlameV2Controller], controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService], providers: [ExpertBlameService],
exports: [ExpertBlameService], exports: [ExpertBlameService],

View File

@@ -7,6 +7,15 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import {
assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
blameCaseStatusToReportBucket,
initialBlameExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -25,6 +34,7 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum"; import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
import { buildFileLink } from "src/helpers/urlCreator"; import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali"; import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { ResendRequestDto } from "./dto/resend.dto"; import { ResendRequestDto } from "./dto/resend.dto";
@@ -32,6 +42,10 @@ import { readFile } from "fs/promises";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service"; import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
import { RequestManagementService } from "src/request-management/request-management.service";
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
interface CheckedRequestEntry { interface CheckedRequestEntry {
CheckedRequest?: { CheckedRequest?: {
@@ -58,6 +72,10 @@ function statementToFormKey(
@Injectable() @Injectable()
export class ExpertBlameService { export class ExpertBlameService {
private readonly logger = new Logger(ExpertBlameService.name); private readonly logger = new Logger(ExpertBlameService.name);
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
constructor( constructor(
private readonly requestManagementDbService: RequestManagementDbService, private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService, private readonly blameRequestDbService: BlameRequestDbService,
@@ -67,8 +85,18 @@ export class ExpertBlameService {
private readonly expertDbService: ExpertDbService, private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService, private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService, private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService,
) { } ) { }
/** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) {
const expertDoc = await this.expertDbService.findOne({
_id: new Types.ObjectId(actorId),
});
return snapshotFromFieldExpert(expertDoc as ExpertModel);
}
async findAll(actor: any): Promise<AllRequestDtoRs> { async findAll(actor: any): Promise<AllRequestDtoRs> {
// 1. Fetch all potentially relevant requests from the database. // 1. Fetch all potentially relevant requests from the database.
// Exclude CAR_BODY type requests as they are automatically handled and don't need expert review // Exclude CAR_BODY type requests as they are automatically handled and don't need expert review
@@ -183,8 +211,41 @@ export class ExpertBlameService {
* 2. Requests where current expert made the decision * 2. Requests where current expert made the decision
* Does NOT show requests decided by other experts. * Does NOT show requests decided by other experts.
*/ */
/**
* V2: Count blame cases for this experts tenant, grouped for dashboard:
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
requireActorClientKey(actor);
const rows = await this.blameRequestDbService.find({}, { lean: true });
const buckets = initialBlameExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue;
const st = String(doc.status ?? "");
const key = blameCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
return buckets;
}
/** Tenant + expert-initiated visibility (same as list, without queue filters). */
private blameDocIncludedInExpertTenantReport(
doc: Record<string, unknown>,
actor: { sub: string; clientKey?: string },
): boolean {
if (!blameCaseAccessibleToExpert(doc, actor)) {
return false;
}
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
return String(doc.initiatedByFieldExpertId) === String(actor.sub);
}
return true;
}
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> { async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try { try {
requireActorClientKey(actor);
const expertId = actor.sub; const expertId = actor.sub;
// Fetch all DISAGREEMENT cases // Fetch all DISAGREEMENT cases
@@ -196,9 +257,25 @@ export class ExpertBlameService {
); );
// Filter to show only: // Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision) // 1. Same insurance tenant (party clientId or expert-initiated by this actor)
// 2. Requests decided by current expert // 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 3. Requests decided by current expert
// 4. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => { const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
if (!blameCaseAccessibleToExpert(doc, actor)) {
return false;
}
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId) {
if (String(initiatedByFieldExpertId) !== expertId) {
return false; // Only the initiating field expert can see this file
}
return true; // Initiating expert can see their expert-initiated file
}
const status = doc.status as string; const status = doc.status as string;
const decision = doc.expert as any; const decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId; const decidedByExpertId = decision?.decision?.decidedByExpertId;
@@ -223,6 +300,35 @@ export class ExpertBlameService {
return false; return false;
}); });
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
const la = w.lockedAt;
if (!la) {
staleIds.add(String(doc._id));
continue;
}
if (
Date.now() >=
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
) {
staleIds.add(String(doc._id));
}
}
await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
);
for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue;
const w = doc.workflow as Record<string, unknown>;
if (w) {
w.locked = false;
delete w.lockedAt;
delete w.lockedBy;
}
}
const items: AllRequestDtoV2[] = visibleCases.map( const items: AllRequestDtoV2[] = visibleCases.map(
(doc) => this.mapBlameRequestToListItemV2(doc), (doc) => this.mapBlameRequestToListItemV2(doc),
); );
@@ -278,6 +384,75 @@ export class ExpertBlameService {
}; };
} }
/**
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
*/
private async expireBlameCaseWorkflowLockV2IfStale(
requestId: string,
): Promise<void> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request?.workflow?.locked) {
return;
}
const lockedAt = request.workflow.lockedAt;
if (
lockedAt &&
Date.now() <
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) {
return;
}
const lockedById = request.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
"workflow.locked": true,
};
if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
String(lockedById),
);
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: { "workflow.locked": false },
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
},
{ new: false },
);
if (!cleared) {
return;
}
if (!request.expert?.decision && lockedById) {
const rid = new Types.ObjectId(requestId).toString();
try {
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(String(lockedById)) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: rid } as any,
},
);
this.logger.warn(
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
);
} catch (e) {
this.logger.warn(
`Expert stat rollback failed after blame lock expiry ${requestId}`,
e,
);
}
}
}
public unlockApi(request, timer) { public unlockApi(request, timer) {
return setTimeout(async () => { return setTimeout(async () => {
try { try {
@@ -509,12 +684,15 @@ export class ExpertBlameService {
* Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence. * Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence.
* Access control: Only allows viewing fresh requests or requests decided by current expert. * Access control: Only allows viewing fresh requests or requests decided by current expert.
*/ */
async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> { async findOneV2(requestId: string, actor: any): Promise<Record<string, unknown>> {
try { try {
requireActorClientKey(actor);
const actorId = actor.sub;
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) { if (!doc) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string; const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) { if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -522,81 +700,126 @@ export class ExpertBlameService {
); );
} }
// Access control: Check if expert has permission to view this request // Access control
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
throw new ForbiddenException(
"Only the field expert who created this file can view and review it.",
);
}
const decision = (doc.expert as any)?.decision; const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId; const decidedByExpertId = decision?.decidedByExpertId;
// Allow if:
// 1. No decision yet (fresh request)
// 2. Decision made by current expert
if (decidedByExpertId && String(decidedByExpertId) !== actorId) { if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.", "You do not have permission to view this request. It has been handled by another expert.",
); );
} }
const parties = (doc.parties ?? []) as Array<{ const parties = Array.isArray(doc.parties) ? doc.parties : [];
evidence?: { videoId?: string; voices?: string[] };
[key: string]: unknown; const typedParties = parties as Array<{
vehicle?: {
inquiry?: {
mapped?: any;
};
};
evidence?: {
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
}>; }>;
for (const party of parties) {
// Evidence (videos + voices)
for (const party of typedParties) {
if (!party.evidence) continue; if (!party.evidence) continue;
const evidence = party.evidence as Record<string, unknown>; const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) { if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById( const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId));
String(evidence.videoId), if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
);
if (videoDoc?.path) {
evidence.videoUrl = buildFileLink(videoDoc.path);
}
} }
if (evidence.voices && Array.isArray(evidence.voices)) { if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = []; const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) { for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById( const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId));
String(voiceId), if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
);
if (voiceDoc?.path) {
voiceUrls.push(buildFileLink(voiceDoc.path));
}
} }
evidence.voiceUrls = voiceUrls; evidence.voiceUrls = voiceUrls;
} }
} }
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); // Date formatting
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Exclude mapped inquiry vehicle for both parties from response // Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields)
for (const party of parties) { for (const party of typedParties) {
delete party.vehicle; const veh = party?.vehicle as Record<string, unknown> | undefined;
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) {
delete veh.inquiry;
}
} }
return doc; return doc;
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
"findOneV2 failed", "findOneV2 failed",
requestId, requestId,
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details", error instanceof Error ? error.message : "Failed to get blame case details",
); );
} }
} }
async lockRequest(requestId: string, actorDetail) { async lockRequest(requestId: string, actorDetail) {
const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) {
throw new NotFoundException("Request not found");
}
if (existing.blameStatus === ReqBlameStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because blameStatus is UserPending",
);
}
const isLocked = !!existing.lockFile;
const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Request is locked by another expert");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updateResult = await this.requestManagementDbService.findOneAndUpdate( const updateResult = await this.requestManagementDbService.findOneAndUpdate(
{ {
_id: requestId, _id: requestId,
lockFile: false,
blameStatus: { $ne: ReqBlameStatus.UserPending }, blameStatus: { $ne: ReqBlameStatus.UserPending },
}, },
{ {
@@ -608,6 +831,7 @@ export class ExpertBlameService {
actorLocked: { actorLocked: {
fullName: actorDetail.fullName, fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub), actorId: new Types.ObjectId(actorDetail.sub),
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
}, },
$push: { $push: {
@@ -642,14 +866,16 @@ export class ExpertBlameService {
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
try { try {
const now = new Date(); const now = new Date();
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
// First, check the current state // First, check the current state
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(request, actorDetail);
// Validate request is available for expert review // Validate request is available for expert review
if ( if (
request.status !== CaseStatus.WAITING_FOR_EXPERT || request.status !== CaseStatus.WAITING_FOR_EXPERT ||
@@ -660,14 +886,28 @@ export class ExpertBlameService {
); );
} }
// Expert-initiated: only the initiating field expert can lock and review
if (
request.expertInitiated &&
request.initiatedByFieldExpertId &&
String(request.initiatedByFieldExpertId) !== actorDetail.sub
) {
throw new ForbiddenException(
"Only the field expert who created this file can lock and review it.",
);
}
// Check if locked and not expired // Check if locked and not expired
if (request.workflow?.locked) { if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt; const lockedAt = request.workflow.lockedAt;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) { if (Date.now() < lockExpiryTime) {
// Lock is still valid // Lock is still valid
const lockedByActorId = String(request.workflow.lockedBy?.actorId); const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) { if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException( throw new BadRequestException(
"You have already locked this request", "You have already locked this request",
@@ -682,6 +922,8 @@ export class ExpertBlameService {
} }
} }
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
// Lock the request (either unlocked or expired lock) // Lock the request (either unlocked or expired lock)
const updateResult = await this.blameRequestDbService.findByIdAndUpdate( const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId, requestId,
@@ -693,6 +935,7 @@ export class ExpertBlameService {
actorId: new Types.ObjectId(actorDetail.sub), actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert", actorName: actorDetail.fullName || "Unknown Expert",
actorRole: "expert", actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
}, },
}, },
@@ -705,6 +948,23 @@ export class ExpertBlameService {
// Update expert stats (reusing existing helper) // Update expert stats (reusing existing helper)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked"); await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
if (request.type === BlameRequestType.THIRD_PARTY) {
const phones = (request.parties || [])
.map((p: any) => p?.person?.phoneNumber)
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
const expertLastName =
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: phone,
publicId: request.publicId,
expertLastName,
},
);
}
}
return { _id: requestId, lock: true }; return { _id: requestId, lock: true };
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -726,15 +986,30 @@ export class ExpertBlameService {
async resendRequestV2( async resendRequestV2(
requestId: string, requestId: string,
resendDto: ResendRequestDto, resendDto: ResendRequestDto,
actorId: string, actor: any,
): Promise<{ requestId: string; status: string }> { ): Promise<{
requestId: string;
status: string;
parties: Array<{
partyId: string;
requestedItems: ResendItemType[];
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
description?: string;
}>;
}> {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(request, actor);
// Validate request is locked by current expert // Validate request is locked by current expert
if (!request.workflow?.locked) { if (!request.workflow?.locked) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -752,7 +1027,8 @@ export class ExpertBlameService {
// Validate lock hasn't expired // Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt; const lockedAt = request.workflow?.lockedAt;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() > lockExpiryTime) { if (Date.now() > lockExpiryTime) {
throw new ForbiddenException( throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.", "Your lock time has expired. Please lock the request again.",
@@ -774,16 +1050,43 @@ export class ExpertBlameService {
); );
} }
const now = new Date(); const allowedItems = new Set<string>(Object.values(ResendItemType));
const partyIdsOnFile = new Set(
(request.parties || [])
.map((p) => (p.person?.userId ? String(p.person.userId) : ""))
.filter(Boolean),
);
// Build resend requests for parties for (const party of resendDto.parties) {
const partyResendRequests = resendDto.parties.map((party) => ({ const uid = String(party.partyId);
partyId: new Types.ObjectId(String(party.partyId)), if (!partyIdsOnFile.has(uid)) {
throw new BadRequestException(
`partyId ${uid} is not a party userId on this blame request.`,
);
}
for (const item of party.requestedItems || []) {
if (!allowedItems.has(String(item))) {
throw new BadRequestException(
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
);
}
}
}
const now = new Date();
const resendSnapshot = await this.snapshotFieldExpert(actorId);
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
return {
partyId: new Types.ObjectId(uid),
requestedItems: party.requestedItems, requestedItems: party.requestedItems,
description: party.description || "", description: party.description || "",
requestedAt: now, requestedAt: now,
completed: false, completed: false,
})); uploadedDocuments: {},
};
});
const updatePayload = { const updatePayload = {
$set: { $set: {
@@ -794,12 +1097,10 @@ export class ExpertBlameService {
parties: partyResendRequests, parties: partyResendRequests,
requestedAt: now, requestedAt: now,
requestedByExpertId: new Types.ObjectId(actorId), requestedByExpertId: new Types.ObjectId(actorId),
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
}, },
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
}, },
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: { $unset: {
"workflow.lockedAt": "", "workflow.lockedAt": "",
"workflow.lockedBy": "", "workflow.lockedBy": "",
@@ -817,11 +1118,21 @@ export class ExpertBlameService {
); );
} }
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
// TODO: Send notifications to parties (SMS/Push) about required documents // TODO: Send notifications to parties (SMS/Push) about required documents
return { return {
requestId: String(request._id), requestId: String(request._id),
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
description: p.description || undefined,
})),
}; };
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -839,19 +1150,30 @@ export class ExpertBlameService {
/** /**
* V2: Submit expert reply for blame case (blameCases collection). * V2: Submit expert reply for blame case (blameCases collection).
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES. * Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
*
* Note: V1 `replyRequest` also handled an “objection” path (`expertResendReply`) by writing
* `expertSubmitReplyFinal` on the legacy request document. Blame V2 uses a single
* `expert.decision` on `blameCases`; resend is modeled as `expert.resend` *before* the first
* decision. If you need a second decision after signatures/objections, extend the schema
* (e.g. decision revision) and branch here similarly to V1.
*/ */
async replyRequestV2( async replyRequestV2(
requestId: string, requestId: string,
reply: SubmitReplyDto, reply: SubmitReplyDto,
actorId: string, actor: any,
): Promise<{ requestId: string; status: string }> { ): Promise<{ requestId: string; status: string }> {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(request, actor);
// Validate no decision exists yet // Validate no decision exists yet
if (request.expert?.decision) { if (request.expert?.decision) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -873,7 +1195,8 @@ export class ExpertBlameService {
// Check if lock has expired (15 minutes) // Check if lock has expired (15 minutes)
let isLockExpired = false; let isLockExpired = false;
if (lockedAt) { if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime; isLockExpired = Date.now() > lockExpiryTime;
} }
@@ -900,12 +1223,15 @@ export class ExpertBlameService {
const now = new Date(); const now = new Date();
const expertProfileSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields // Build expert decision with fields
const decisionPayload: any = { const decisionPayload: any = {
guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)), guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)),
description: reply.description, description: reply.description,
decidedAt: now, decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId), decidedByExpertId: new Types.ObjectId(actorId),
...(expertProfileSnapshot && { expertProfileSnapshot }),
fields: { fields: {
accidentWay: { accidentWay: {
id: reply.fields.accidentWay.id, id: reply.fields.accidentWay.id,
@@ -968,6 +1294,128 @@ export class ExpertBlameService {
} }
} }
async submitInPerson(
requestId: string,
actorId: string,
) {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
const inPersonSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: null,
description: "حضوری مراجعه شود.",
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
...(inPersonSnapshot && { expertProfileSnapshot: inPersonSnapshot }),
fields: {
accidentWay: null,
accidentReason: null,
accidentType: null,
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "COMPLETED",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.STOPPED,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"submitInPerson failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply",
);
}
}
private async updateDamageExpertStats( private async updateDamageExpertStats(
expertId: string, expertId: string,
requestId: string, requestId: string,
@@ -1074,6 +1522,8 @@ export class ExpertBlameService {
); );
} }
const expertProfileSnapshot = await this.snapshotFieldExpert(userId);
const newReplyObject = { const newReplyObject = {
description: reply.description, description: reply.description,
submitTime: new Date(), submitTime: new Date(),
@@ -1095,6 +1545,7 @@ export class ExpertBlameService {
}, },
firstPartyComment: request.expertSubmitReply?.firstPartyComment || null, firstPartyComment: request.expertSubmitReply?.firstPartyComment || null,
secondPartyComment: request.expertSubmitReply?.secondPartyComment || null, secondPartyComment: request.expertSubmitReply?.secondPartyComment || null,
...(expertProfileSnapshot && { expertProfileSnapshot }),
}; };
const updatePayload: any = { const updatePayload: any = {
@@ -1151,6 +1602,7 @@ export class ExpertBlameService {
} }
const partyType = req.route.path.split("/")[4]; const partyType = req.route.path.split("/")[4];
const resendSnapshot = await this.snapshotFieldExpert(userId);
switch (partyType) { switch (partyType) {
case "first": { case "first": {
@@ -1171,6 +1623,9 @@ export class ExpertBlameService {
"expertResendReply.firstParty.firstPartyId": firstPartyId, "expertResendReply.firstParty.firstPartyId": firstPartyId,
"expertResendReply.firstParty.firstPartyDescription": "expertResendReply.firstParty.firstPartyDescription":
firstPartyDescription, firstPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: { $push: {
actorsChecker: { actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked, [ReqBlameStatus.UserPending]: request.actorLocked,
@@ -1208,6 +1663,9 @@ export class ExpertBlameService {
"expertResendReply.secondParty.secondPartyId": secondPartyId, "expertResendReply.secondParty.secondPartyId": secondPartyId,
"expertResendReply.secondParty.secondPartyDescription": "expertResendReply.secondParty.secondPartyDescription":
secondPartyDescription, secondPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: { $push: {
actorsChecker: { actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked, [ReqBlameStatus.UserPending]: request.actorLocked,
@@ -1287,10 +1745,15 @@ export class ExpertBlameService {
throw new NotFoundException("Blame not found"); throw new NotFoundException("Blame not found");
} }
const visitSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updated = await this.requestManagementDbService.findAndUpdate( const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) }, { _id: new Types.ObjectId(requestId) },
{ {
blameStatus: ReqBlameStatus.InPersonVisit, blameStatus: ReqBlameStatus.InPersonVisit,
...(visitSnapshot && {
fieldExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
}, },
); );
@@ -1302,4 +1765,5 @@ export class ExpertBlameService {
return updated; return updated;
} }
} }

View File

@@ -8,7 +8,7 @@ import {
Put, Put,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
@@ -22,7 +22,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame") @Controller("v2/expert-blame")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller { export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) { } constructor(private readonly expertBlameService: ExpertBlameService) { }
@@ -38,11 +38,28 @@ export class ExpertBlameV2Controller {
} }
} }
@Get("report/status-counts")
@ApiOperation({
summary: "Count blame cases by grouped status bucket (tenant)",
description:
"IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.",
})
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
try {
return await this.expertBlameService.getStatusReportBucketsV2(actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to load blame status report",
);
}
}
@Get(":id") @Get(":id")
@ApiParam({ name: "id", description: "Blame case request id" }) @ApiParam({ name: "id", description: "Blame case request id" })
async findOne(@Param("id") id: string, @CurrentUser() actor: any) { async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
try { try {
return await this.expertBlameService.findOneV2(id, actor.sub); return await this.expertBlameService.findOneV2(id, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -73,7 +90,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any, @CurrentUser() actor: any,
) { ) {
try { try {
return await this.expertBlameService.resendRequestV2(id, body, actor.sub); return await this.expertBlameService.resendRequestV2(id, body, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -93,7 +110,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any, @CurrentUser() actor: any,
) { ) {
try { try {
return await this.expertBlameService.replyRequestV2(id, body, actor.sub); return await this.expertBlameService.replyRequestV2(id, body, actor);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -101,4 +118,22 @@ export class ExpertBlameV2Controller {
); );
} }
} }
@Put("reply/inPerson/:id")
@ApiParam({ name: "id", description: "Blame case request id" })
@ApiBody({ type: SubmitReplyDto })
async submitInPerson(
@Param("id") id: string,
@Body() body: SubmitReplyDto,
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.submitInPerson(id, actor.sub);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply for in person",
);
}
}
} }

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
export class ClaimDetailV2ResponseDto { export class ClaimDetailV2ResponseDto {
@ApiProperty() @ApiProperty()
@@ -43,6 +44,19 @@ export class ClaimDetailV2ResponseDto {
plate?: any; plate?: any;
}; };
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' }) @ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
blameRequestId?: string; blameRequestId?: string;
@@ -69,6 +83,52 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>; damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
'True when user uploaded all required factors and the case awaits expert approve/reject.',
})
awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
'Expert reply payloads (first and/or final) when awaiting factor validation.',
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
@ApiPropertyOptional({
description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
})
videoCapture?: {
id: string;
url?: string;
path?: string;
fileName?: string;
};
@ApiPropertyOptional({
description:
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
})
blameCase?: Record<string, unknown>;
@ApiPropertyOptional({
description:
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
})
blameExpertDecision?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
})
money?: {
sheba?: string;
nationalCodeOfInsurer?: string;
};
@ApiProperty() @ApiProperty()
createdAt: string; createdAt: string;

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
export class ClaimListItemV2Dto { export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID' }) @ApiProperty({ description: 'Claim case ID' })
@@ -29,8 +30,28 @@ export class ClaimListItemV2Dto {
carType?: string; carType?: string;
}; };
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
example: BlameRequestType.THIRD_PARTY,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' }) @ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
createdAt: string; createdAt: string;
@ApiPropertyOptional({
description:
'True when the claim is in the expert factor validation queue (all factors uploaded).',
})
awaitingFactorValidation?: boolean;
} }
export class GetClaimListV2ResponseDto { export class GetClaimListV2ResponseDto {

View File

@@ -6,8 +6,12 @@ import {
IsBoolean, IsBoolean,
IsOptional, IsOptional,
ValidateNested, ValidateNested,
IsEnum,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
export class CarPartDamageV2Dto { export class CarPartDamageV2Dto {
@ApiProperty({ example: 'left' }) @ApiProperty({ example: 'left' })
@@ -19,6 +23,31 @@ export class CarPartDamageV2Dto {
part: string; part: string;
} }
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
export class DaghiDetailsV2Dto {
@ApiProperty({
enum: DaghiOption,
description:
'Daghi option: ارزش لوازم بازیافتی, تحویل داغی, فاقد ارزش, با احتساب داغی',
})
@IsEnum(DaghiOption)
option: DaghiOption;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
})
@IsOptional()
@IsString()
price?: string;
@ApiPropertyOptional({
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
})
@IsOptional()
@IsString()
branchId?: string;
}
export class PartPricingV2Dto { export class PartPricingV2Dto {
@ApiProperty({ example: 'part-001' }) @ApiProperty({ example: 'part-001' })
@IsString() @IsString()
@@ -46,10 +75,10 @@ export class PartPricingV2Dto {
@IsString() @IsString()
totalPayment: string; totalPayment: string;
@ApiPropertyOptional({ example: '500000' }) @ApiProperty({ type: DaghiDetailsV2Dto })
@IsOptional() @ValidateNested()
@IsString() @Type(() => DaghiDetailsV2Dto)
daghi?: string; daghi: DaghiDetailsV2Dto;
@ApiProperty({ example: false }) @ApiProperty({ example: false })
@IsBoolean() @IsBoolean()
@@ -68,3 +97,30 @@ export class SubmitExpertReplyV2Dto {
@Type(() => PartPricingV2Dto) @Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[]; parts: PartPricingV2Dto[];
} }
export class ClaimSubmitResendV2Dto {
@ApiPropertyOptional({
description:
"Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.",
})
@IsOptional()
@IsString()
resendDescription?: string;
@ApiPropertyOptional({
type: [String],
enum: ClaimRequiredDocumentType,
description: "Extra document keys to upload (may include types not in the initial claim form).",
})
@IsOptional()
@IsArray()
@IsEnum(ClaimRequiredDocumentType, { each: true })
resendDocuments?: ClaimRequiredDocumentType[];
@ApiPropertyOptional({ type: [DamagedPartItem] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DamagedPartItem)
resendCarParts?: DamagedPartItem[];
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import {
IsArray, IsArray,
@@ -31,3 +31,45 @@ export class FactorValidationDto {
@Type(() => FactorDecisionDto) @Type(() => FactorDecisionDto)
decisions: FactorDecisionDto[]; decisions: FactorDecisionDto[];
} }
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
class FactorDecisionV2Dto {
@ApiProperty()
@IsString()
partId: string;
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
status: FactorStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
rejectionReason?: string;
@ApiPropertyOptional({
description:
"Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).",
})
@IsOptional()
@IsString()
price?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
salary?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
totalPayment?: string;
}
export class FactorValidationV2Dto {
@ApiProperty({ type: [FactorDecisionV2Dto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FactorDecisionV2Dto)
decisions: FactorDecisionV2Dto[];
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
IsNotEmpty,
} from "class-validator";
import { OuterCarPart } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
export class UpdateClaimDamagedPartsV2Dto {
@ApiProperty({
description: "Updated list of selected damaged outer parts",
enum: OuterCarPart,
isArray: true,
example: ["hood", "front_bumper", "front_left_fender"],
})
@IsNotEmpty()
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@IsEnum(OuterCarPart, { each: true })
selectedParts: OuterCarPart[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiPropertyOptional, ApiPropertyOptional,
ApiQuery,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator"; import { IsOptional, IsString } from "class-validator";
@@ -14,7 +15,9 @@ 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 { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
class InPersonVisitV2Dto { class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@@ -31,31 +34,38 @@ class InPersonVisitV2Dto {
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) { } constructor(private readonly expertClaimService: ExpertClaimService) { }
@Get("report/status-counts")
@ApiOperation({
summary: "Count claim cases by grouped status bucket (tenant)",
description:
"IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.",
})
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
return await this.expertClaimService.getStatusReportBucketsV2(actor);
}
@Get("requests") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
description: description:
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.", "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
}) })
async getClaimListV2(@CurrentUser() actor) { async getClaimListV2(@CurrentUser() actor) {
return await this.expertClaimService.getClaimListV2(actor.sub); return await this.expertClaimService.getClaimListV2(actor);
} }
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Get claim request detail for damage expert", summary: "Get claim request detail for damage expert",
description: description:
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.", "Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async getClaimDetailV2( async getClaimDetailV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor, @CurrentUser() actor,
) { ) {
return await this.expertClaimService.getClaimDetailV2( return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
claimRequestId,
actor.sub,
);
} }
@Put("lock/:claimRequestId") @Put("lock/:claimRequestId")
@@ -76,7 +86,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Submit expert damage assessment reply", summary: "Submit expert damage assessment reply",
description: description:
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", "Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. Each part requires `daghi` with the same rules as V1 (DaghiOption, price for recycled value, branchId for deliver damaged part). If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto }) @ApiBody({ type: SubmitExpertReplyV2Dto })
@@ -88,6 +98,23 @@ export class ExpertClaimV2Controller {
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor); return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
} }
@Put("reply/resend/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage resend request",
description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto })
async submitExpertResendV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: ClaimSubmitResendV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
}
@Patch(":claimRequestId/visit") @Patch(":claimRequestId/visit")
@ApiOperation({ @ApiOperation({
summary: "Request in-person visit for claim", summary: "Request in-person visit for claim",
@@ -107,4 +134,61 @@ export class ExpertClaimV2Controller {
body?.note, body?.note,
); );
} }
@Patch("validate-factors/:claimRequestId")
@ApiOperation({
summary: "Validate uploaded repair factors (V2 ClaimCase)",
description:
"After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FactorValidationV2Dto })
async validateClaimFactorsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FactorValidationV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.validateClaimFactorsV2(
claimRequestId,
body,
actor,
);
}
@Patch("request/:claimRequestId/damaged-parts")
@ApiOperation({
summary: "Edit selected damaged parts (V2)",
description:
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
async updateClaimDamagedPartsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UpdateClaimDamagedPartsV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.updateClaimDamagedPartsV2(
claimRequestId,
body,
actor,
);
}
@ApiQuery({
name: "query",
enum: ["car-capture", "accident"],
required: true,
})
@Get("stream/:id/video")
@Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4")
async claimStream(
@Headers() headers,
@Param("id") id: string,
@Query("query") query: "car-capture" | "accident",
@Res() res,
) {
return this.expertClaimService.streamServiceV2(id, query, res, headers);
}
} }

View File

@@ -78,6 +78,7 @@ export class ExpertInsurerController {
@ApiQuery({ name: "role", enum: ["claim", "blame"] }) @ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId") @Get("/:expertId")
async requestDetail( async requestDetail(
@CurrentUser() insurer,
@Param("expertId") id: string, @Param("expertId") id: string,
@Query("role") role: "claim" | "blame", @Query("role") role: "claim" | "blame",
) { ) {
@@ -89,7 +90,11 @@ export class ExpertInsurerController {
throw new BadRequestException("Invalid role"); throw new BadRequestException("Invalid role");
} }
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role); return await this.expertInsurerService.getAllFilesByExpertAndRole(
id,
role,
insurer.clientKey,
);
} }
@ApiBody({ @ApiBody({
@@ -153,6 +158,7 @@ export class ExpertInsurerController {
@ApiQuery({ name: "role", enum: ["claim", "blame"] }) @ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Put("/:requestId/rating") @Put("/:requestId/rating")
async rateExperts( async rateExperts(
@CurrentUser() insurer,
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@Body() rating: FileRating, @Body() rating: FileRating,
@Query("role") role: "claim" | "blame", @Query("role") role: "claim" | "blame",
@@ -165,13 +171,13 @@ export class ExpertInsurerController {
requestId, requestId,
rating, rating,
role, role,
insurer.clientKey,
); );
} }
@Get("branches/:insuranceId") @Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" }) @ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
console.log("insuranceId", insuranceId);
return await this.expertInsurerService.retrieveInsuranceBranches( return await this.expertInsurerService.retrieveInsuranceBranches(
insuranceId, insuranceId,
); );

View File

@@ -1,163 +1,159 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { Types } from "mongoose";
import { Model, Types } from "mongoose"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { buildFileLink } from "src/helpers/urlCreator";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
import { import {
FileRating, blameCaseTouchesClient,
RequestManagementModel, claimCaseTouchesClient,
} from "src/request-management/entities/schema/request-management.schema"; } from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
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";
@Injectable() @Injectable()
export class ExpertInsurerService { export class ExpertInsurerService {
private readonly logger = new Logger(ExpertInsurerService.name);
constructor( constructor(
private readonly client: ClientDbService,
private readonly expertDbService: ExpertDbService, private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
@InjectModel(ClaimRequestManagementModel.name) private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>, private readonly claimCaseDbService: ClaimCaseDbService,
@InjectModel(RequestManagementModel.name)
private readonly requestManagementModel: Model<RequestManagementModel>,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly branchDbService: BranchDbService, private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly damageImageDbService: DamageImageDbService,
) {} ) {}
async retrieveAllExpertsOfClient( private getClientId(actorOrId: any): Types.ObjectId {
actor, const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
currentPage: number, if (!raw || !Types.ObjectId.isValid(raw)) {
countPerPage: number, throw new BadRequestException("Client key is required");
) { }
const { clientKey } = actor; return new Types.ObjectId(raw);
if (!clientKey) return; }
const clientObjectId = new Types.ObjectId(clientKey); private parseObjectId(value: string, label: string): Types.ObjectId {
if (!Types.ObjectId.isValid(value)) {
throw new BadRequestException(`Invalid ${label}`);
}
return new Types.ObjectId(value);
}
const [experts, damageExperts] = await Promise.all([ private normalizeClaimCase(claim: any): any {
this.expertDbService.findAll({ clientKey: clientKey }), return {
this.damageExpertDbService.findAll({ clientKey: clientObjectId }), ...claim,
]); requestNumber: claim.requestNo,
const allExpertsRaw = [...experts, ...damageExperts]; userClientKey: claim.owner?.userClientKey,
const expertIds = allExpertsRaw.map((expert) => expert._id.toString()); fullName: claim.owner?.fullName,
carDetail: claim.vehicle,
carPlate: claim.vehicle?.plate,
claimStatus: claim.status,
rating: claim.evaluation?.rating,
userRating: claim.evaluation?.userRating,
damageExpertReply: claim.evaluation?.damageExpertReply,
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
damageExpertResend: claim.evaluation?.damageExpertResend,
objection: claim.evaluation?.objection,
userResendDocuments: claim.evaluation?.userResendDocuments,
priceDrop: claim.evaluation?.priceDrop,
visitLocation: claim.evaluation?.visitLocation,
currentStep: claim.workflow?.currentStep,
nextStep: claim.workflow?.nextStep,
};
}
if (expertIds.length === 0) private normalizeBlameCase(blame: any): any {
return { total: 0, page: currentPage, countPerPage, experts: [] }; const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
const expertObjectIds = expertIds.map((id) => new Types.ObjectId(id)); return {
...blame,
const blameFileQuery = { requestNumber: blame.requestNo,
$and: [ rating: blame?.expert?.rating,
{ actorLocked: { actorId: blame?.expert?.assignedExpertId },
$or: [ firstPartyDetails: {
{ "firstPartyDetails.firstPartyClient.clientId": clientObjectId }, firstPartyClient: { clientId: firstParty?.person?.clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientObjectId }, },
], secondPartyDetails: {
secondPartyClient: { clientId: secondParty?.person?.clientId },
}, },
{ "actorLocked.actorId": { $in: expertObjectIds } },
],
}; };
}
const claimFileQuery = { private getCombinedFileScore(file: any): number | null {
$and: [
{ userClientKey: clientObjectId },
{ "damageExpertReply.actorDetail.actorId": { $in: expertIds } },
],
};
const [blameFiles, claimFiles] = await Promise.all([
this.requestManagementModel.find(blameFileQuery).lean(),
this.claimRequestManagementModel.find(claimFileQuery).lean(),
]);
const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<
string,
Record<string, number[]>
> = {};
/**
* Converts insurer FileRating and optional userRating into a single
* combined score for a file.
*/
const getCombinedFileScore = (file: any): number | null => {
const insurerRating = file?.rating; const insurerRating = file?.rating;
const userRating = file?.userRating; const userRating = file?.userRating;
const insurerValues = insurerRating const insurerValues = insurerRating
? Object.values(insurerRating).filter( ? Object.values(insurerRating).filter(
(val): val is number => typeof val === "number" && !isNaN(val), (v): v is number => typeof v === "number" && !isNaN(v),
) )
: []; : [];
const insurerAvg = const userValues = userRating
insurerValues.length > 0 ? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
(v) => typeof v === "number" && !isNaN(v),
)
: [];
const insurerAvg = insurerValues.length
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
: null; : null;
const userAvg = userValues.length
const userValues = userRating
? [
userRating.progressSpeed,
userRating.registrationEase,
userRating.overallEvaluation,
].filter((val) => typeof val === "number" && !isNaN(val))
: [];
const userAvg =
userValues.length > 0
? userValues.reduce((a, b) => a + b, 0) / userValues.length ? userValues.reduce((a, b) => a + b, 0) / userValues.length
: null; : null;
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number");
if (!scores.length) return null;
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
}
const scores = [insurerAvg, userAvg].filter( private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
(v): v is number => typeof v === "number" && !isNaN(v), const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
); const idStr = String(clientObjectId);
if (scores.length === 0) return null; return all
return parseFloat( .filter((f) =>
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2), (f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
); )
}; .filter(
(f) =>
f?.status !== CaseStatus.OPEN &&
f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY,
)
.map((f) => this.normalizeBlameCase(f));
}
const processRatings = (expertId: string, file: any) => { private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
const idStr = String(clientObjectId);
return all
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
.map((f) => this.normalizeClaimCase(f));
}
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
const clientObjectId = this.getClientId(actor);
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const allExpertsRaw = [...experts, ...damageExperts];
const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
const processRatings = (expertId: string | undefined, file: any) => {
if (!expertId) return; if (!expertId) return;
const rating = file?.rating; const rating = file?.rating;
const combinedScore = getCombinedFileScore(file); const combinedScore = this.getCombinedFileScore(file);
// Aggregate overall combined score for this expert
if (combinedScore !== null) { if (combinedScore !== null) {
if (!expertTotalRatingsMap[expertId]) if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId].push(combinedScore); expertTotalRatingsMap[expertId].push(combinedScore);
} }
// Keep backward-compatible per-category insurer ratings
if (!rating || typeof rating !== "object") return; if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
if (!expertRatingsByCategoryMap[expertId]) {
expertRatingsByCategoryMap[expertId] = {};
}
for (const [category, value] of Object.entries(rating)) { for (const [category, value] of Object.entries(rating)) {
if (typeof value === "number" && !isNaN(value)) { if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) { if (!expertRatingsByCategoryMap[expertId][category]) {
@@ -169,37 +165,29 @@ export class ExpertInsurerService {
}; };
for (const file of blameFiles) { for (const file of blameFiles) {
const expertId = file?.actorLocked?.actorId?.toString(); processRatings(file?.actorLocked?.actorId?.toString?.(), file);
processRatings(expertId, file);
} }
for (const file of claimFiles) { for (const file of claimFiles) {
const expertId = file?.damageExpertReply?.actorDetail?.actorId; processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
processRatings(expertId, file);
} }
const allExperts = allExpertsRaw.map((expert) => { const allExperts = allExpertsRaw.map((expert) => {
const expertIdStr = expert._id.toString(); const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length const overallAverageRating = totalRatings.length
? parseFloat( ? parseFloat(
( (totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
).toFixed(2),
) )
: null; : null;
const averageRatingsByCategory: Record<string, number> = {}; const averageRatingsByCategory: Record<string, number> = {};
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr]; const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
if (ratingsByCat) { if (ratingsByCat) {
for (const [category, values] of Object.entries(ratingsByCat)) { for (const [category, values] of Object.entries(ratingsByCat)) {
const sum = values.reduce((a, b) => a + b, 0); averageRatingsByCategory[category] = parseFloat(
const average = parseFloat((sum / values.length).toFixed(2)); (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
averageRatingsByCategory[category] = average; );
} }
} }
return { return {
_id: expert._id, _id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`, fullName: `${expert.firstName} ${expert.lastName}`,
@@ -212,14 +200,14 @@ export class ExpertInsurerService {
}; };
}); });
const start = (currentPage - 1) * countPerPage; const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const paginated = allExperts.slice(start, start + countPerPage); const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
const start = (page - 1) * perPage;
return { return {
total: allExperts.length, total: allExperts.length,
page: currentPage, page,
countPerPage, countPerPage: perPage,
experts: paginated, experts: allExperts.slice(start, start + perPage),
}; };
} }
@@ -245,510 +233,125 @@ export class ExpertInsurerService {
* combined insurer + user ratings. * combined insurer + user ratings.
*/ */
async getTopFilesForClient(insurerId: string): Promise<any[]> { async getTopFilesForClient(insurerId: string): Promise<any[]> {
const id = new Types.ObjectId(insurerId); const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
const claimFiles = await this.claimRequestManagementModel
.find(
{
userClientKey: id,
},
{
requestNumber: 1,
userClientKey: 1,
userId: 1,
fullName: 1,
carDetail: 1,
carPlate: 1,
claimStatus: 1,
rating: 1,
userRating: 1,
damageExpertReply: 1,
damageExpertReplyFinal: 1,
createdAt: 1,
},
)
.lean();
const scored = claimFiles const scored = claimFiles
.map((file) => { .map((file) => {
const insurerRating = file?.rating; const combinedScore = this.getCombinedFileScore(file);
const userRating = file?.userRating; if (combinedScore === null) return null;
return { ...file, combinedScore };
const insurerValues = insurerRating
? Object.values(insurerRating).filter(
(val): val is number => typeof val === "number" && !isNaN(val),
)
: [];
const insurerAvg =
insurerValues.length > 0
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
: null;
const userValues = userRating
? [
userRating.progressSpeed,
userRating.registrationEase,
userRating.overallEvaluation,
].filter((val) => typeof val === "number" && !isNaN(val))
: [];
const userAvg =
userValues.length > 0
? userValues.reduce((a, b) => a + b, 0) / userValues.length
: null;
const scores = [insurerAvg, userAvg].filter(
(v): v is number => typeof v === "number" && !isNaN(v),
);
if (scores.length === 0) return null;
const combinedScore =
scores.reduce((a, b) => a + b, 0) / scores.length;
return {
...file,
combinedScore: parseFloat(combinedScore.toFixed(2)),
};
}) })
.filter((f) => f !== null); .filter((f) => f !== null);
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore);
return sorted.slice(0, 10);
} }
async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") { private async assertExpertBelongsToInsurer(
const expertObjectId = new Types.ObjectId(expertId); expertObjectId: Types.ObjectId,
insurerClientKey: string,
const model = ) {
role === "claim" const ck = String(insurerClientKey);
? this.claimRequestManagementModel const clientOid = new Types.ObjectId(ck);
: this.requestManagementModel; const [experts, damageExperts] = await Promise.all([
const expertCollection = role === "claim" ? "damage-expert" : "expert"; this.expertDbService.findAll({ clientKey: ck }),
this.damageExpertDbService.findAll({ clientKey: clientOid }),
const results = await model.aggregate([
{ $match: { "actorLocked.actorId": expertObjectId } },
{
$lookup: {
from: expertCollection,
localField: "actorLocked.actorId",
foreignField: "_id",
as: "expertInfo",
},
},
{ $unwind: { path: "$expertInfo", preserveNullAndEmptyArrays: true } },
{
$addFields: {
averageRating: {
$cond: [
{ $ne: ["$rating", null] },
{
$avg: [
"$rating.collisionMethodAccuracy",
"$rating.evaluationTimeliness",
"$rating.accidentCauseAccuracy",
"$rating.guiltyVehicleIdentification",
],
},
null,
],
},
},
},
{
$project: {
requestNumber: 1,
userClientKey: 1,
userId: 1,
fullName: 1,
carDetail: 1,
claimStatus: 1,
steps: 1,
currentStep: 1,
rating: 1,
averageRating: 1,
imageRequired: 1,
expertInfo: {
_id: 1,
fullName: {
$concat: ["$expertInfo.firstName", " ", "$expertInfo.lastName"],
},
requestStats: 1,
userType: 1,
createdAt: 1,
},
},
},
]); ]);
const id = String(expertObjectId);
// Process imageRequired for claim files const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
if (role === "claim") { if (!ok) {
return await Promise.all( throw new ForbiddenException(
results.map((file) => this.processImageRequired(file)), "This expert is not registered under your insurance company.",
); );
} }
}
return results; async getAllFilesByExpertAndRole(
expertId: string,
role: "claim" | "blame",
insurerClientKey: string,
) {
const expertObjectId = this.parseObjectId(expertId, "expert id");
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
const clientKeyStr = String(this.getClientId(insurerClientKey));
if (role === "claim") {
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
return claims
.filter(
(c) =>
claimCaseTouchesClient(c, clientKeyStr) &&
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
String(expertObjectId),
)
.map((c) => this.normalizeClaimCase(c));
}
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
return blames
.filter(
(b) =>
blameCaseTouchesClient(b, clientKeyStr) &&
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
)
.map((b) => this.normalizeBlameCase(b));
} }
async rateExpertOnFile( async rateExpertOnFile(
requestId: string, requestId: string,
rating: FileRating, rating: FileRating,
role: "claim" | "blame", role: "claim" | "blame",
insurerClientKey: string,
) { ) {
const _id = new Types.ObjectId(requestId); const _id = this.parseObjectId(requestId, "request id");
const clientKeyStr = String(this.getClientId(insurerClientKey));
// Validate each rating factor for (const [key, value] of Object.entries(rating || {})) {
const fields = Object.entries(rating);
for (const [key, value] of fields) {
if (typeof value !== "number" || value < 0 || value > 5) { if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException( throw new BadRequestException(`${key} must be a number between 0 and 5`);
`${key} must be a number between 0 and 5`,
);
} }
} }
if (role === "claim") { if (role === "claim") {
const updated = await this.claimRequestManagementModel.findByIdAndUpdate( const existing = await this.claimCaseDbService.findById(_id);
_id, if (!existing) throw new NotFoundException("Claim file not found");
{ $set: { rating } }, if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
{ new: true }, throw new ForbiddenException("This claim does not belong to your organization.");
);
if (!updated) {
throw new NotFoundException("Claim file not found");
} }
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
$set: { "evaluation.rating": rating },
});
if (!updated) throw new NotFoundException("Claim file not found");
return { return {
message: "Claim expert rated successfully", message: "Claim expert rated successfully",
updatedRating: updated.rating, updatedRating: (updated as any)?.evaluation?.rating || rating,
requestId: updated._id, requestId: updated._id,
}; };
} }
if (role === "blame") { if (role === "blame") {
const updated = await this.requestManagementModel.findByIdAndUpdate( const existing = await this.blameRequestDbService.findById(_id);
_id, if (!existing) throw new NotFoundException("Blame file not found");
{ $set: { rating } }, if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
{ new: true }, throw new ForbiddenException("This blame case does not belong to your organization.");
);
if (!updated) {
throw new NotFoundException("Blame file not found");
} }
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
$set: { "expert.rating": rating },
});
if (!updated) throw new NotFoundException("Blame file not found");
return { return {
message: "Blame expert rated successfully", message: "Blame expert rated successfully",
updatedRating: updated.rating, updatedRating: (updated as any)?.expert?.rating || rating,
requestId: updated._id, requestId: updated._id,
}; };
} }
throw new BadRequestException("Invalid role"); throw new BadRequestException("Invalid role");
} }
async retrieveAllFilesOfClient(insurerId: string) { async retrieveAllFilesOfClient(insurerId: string) {
const id = new Types.ObjectId(insurerId); const id = this.getClientId(insurerId);
const [blameFiles, claimFiles] = await Promise.all([
const blameFilesRaw = await this.requestManagementModel this.getClientBlameFiles(id),
.find({ this.getClientClaimFiles(id),
"firstPartyDetails.firstPartyClient.clientId": id, ]);
blameStatus: { return { blameFiles, claimFiles };
$nin: ["PendingForFirstParty", "PendingForSecondParty"],
},
})
.lean();
const claimFilesRaw = await this.claimRequestManagementModel
.find({
userClientKey: id,
}, {
_id: 1,
requestNumber: 1,
userClientKey: 1,
userId: 1,
fullName: 1,
carDetail: 1,
carPlate: 1,
claimStatus: 1,
currentStep: 1,
nextStep: 1,
carPartDamage: 1,
otherParts: 1,
sheba: 1,
nationalCodeOfInsurer: 1,
carGreenCard: 1,
aiImages: 1,
imageRequired: 1,
videoCaptureId: 1,
damageExpertReply: 1,
damageExpertReplyFinal: 1,
damageExpertResend: 1,
objection: 1,
userResendDocuments: 1,
priceDrop: 1,
requiredDocuments: 1,
createdAt: 1,
updatedAt: 1,
rating: 1,
visitLocation: 1,
})
.lean();
const populatedBlameFiles = await Promise.all(
blameFilesRaw.map((file) => this.populateBlameFileLinks(file)),
);
const populatedClaimFiles = await Promise.all(
claimFilesRaw.map(async (file) => {
const populated = await this.populateClaimFileLinks(file);
return await this.processImageRequired(populated);
}),
);
return { blameFiles: populatedBlameFiles, claimFiles: populatedClaimFiles };
}
private async populateBlameFileLinks(blameFile: any): Promise<any> {
if (!blameFile) return blameFile;
// --- FIX: Consistently use findById ---
if (blameFile.firstPartyDetails?.firstPartyFile?.firstPartyVideoId) {
const videoDoc = await this.blameVideoDbService.findById(
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId.toString(),
);
if (videoDoc)
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId =
buildFileLink(videoDoc.path);
}
if (Array.isArray(blameFile.firstPartyDetails?.firstPartyFile?.voices)) {
blameFile.firstPartyDetails.firstPartyFile.voices =
await this.populateIdArray(
this.blameVoiceDbService,
blameFile.firstPartyDetails.firstPartyFile.voices,
);
}
if (Array.isArray(blameFile.secondPartyDetails?.secondPartyFiles?.voices)) {
blameFile.secondPartyDetails.secondPartyFiles.voices =
await this.populateIdArray(
this.blameVoiceDbService,
blameFile.secondPartyDetails.secondPartyFiles.voices,
);
}
if (blameFile.expertResendReply) {
await this.populatePartyReplyLinks(
blameFile.expertResendReply.firstParty,
);
await this.populatePartyReplyLinks(
blameFile.expertResendReply.secondParty,
);
}
const finalReply =
blameFile.expertSubmitReplyFinal || blameFile.expertSubmitReply;
if (finalReply) {
await this.populateSignatureLink(finalReply.firstPartyComment);
await this.populateSignatureLink(finalReply.secondPartyComment);
}
return blameFile;
}
private async populateClaimFileLinks(claimFile: any): Promise<any> {
if (!claimFile) return claimFile;
if (claimFile.blameFile) {
claimFile.blameFile = await this.populateBlameFileLinks(
claimFile.blameFile,
);
}
// --- FIX: Consistently use findById ---
if (claimFile.videoCaptureId) {
const videoDoc = await this.claimVideoCaptureDbService.findById(
claimFile.videoCaptureId._id.toString(),
);
if (videoDoc) claimFile.videoCaptureId = buildFileLink(videoDoc.path);
}
if (claimFile.requiredDocuments) {
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
claimFile._id.toString(),
);
// Populate with file URLs
const populatedDocuments = documents.map((doc) => ({
_id: doc._id,
documentType: doc.documentType,
fileName: doc.fileName,
fileUrl: buildFileLink(doc.path),
uploadedAt: doc.uploadedAt,
}));
claimFile.requiredDocuments = populatedDocuments;
}
return claimFile;
}
/**
* Processes imageRequired field:
* 1. Removes part_segments from aiReport.distinct_damaged_parts_report.parts[]
* 2. Populates imageId fields with file links
*/
private async processImageRequired(claimFile: any): Promise<any> {
if (!claimFile || !claimFile.imageRequired) {
return claimFile;
}
const imageRequired = claimFile.imageRequired;
// Process aroundTheCar array
if (Array.isArray(imageRequired.aroundTheCar)) {
imageRequired.aroundTheCar = await Promise.all(
imageRequired.aroundTheCar.map(async (item: any) => {
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
if (
item?.aiReport?.distinct_damaged_parts_report?.parts &&
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
) {
item.aiReport.distinct_damaged_parts_report.parts =
item.aiReport.distinct_damaged_parts_report.parts.map(
(part: any) => {
const { part_segments, ...partWithoutSegments } = part;
return partWithoutSegments;
},
);
}
// Populate imageId with file link
if (item?.imageId) {
try {
const imageDoc = await this.damageImageDbService.findOne(
item.imageId.toString(),
);
if (imageDoc && imageDoc.path) {
item.imageId = buildFileLink(imageDoc.path);
}
} catch (error) {
this.logger.warn(
`Failed to populate imageId for aroundTheCar item: ${error.message}`,
);
}
}
return item;
}),
);
}
// Process selectPartOfCar array
if (Array.isArray(imageRequired.selectPartOfCar)) {
imageRequired.selectPartOfCar = await Promise.all(
imageRequired.selectPartOfCar.map(async (item: any) => {
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
if (
item?.aiReport?.distinct_damaged_parts_report?.parts &&
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
) {
item.aiReport.distinct_damaged_parts_report.parts =
item.aiReport.distinct_damaged_parts_report.parts.map(
(part: any) => {
const { part_segments, ...partWithoutSegments } = part;
return partWithoutSegments;
},
);
}
// Populate imageId with file link
if (item?.imageId) {
try {
const imageDoc = await this.damageImageDbService.findOne(
item.imageId.toString(),
);
if (imageDoc && imageDoc.path) {
item.imageId = buildFileLink(imageDoc.path);
}
} catch (error) {
this.logger.warn(
`Failed to populate imageId for selectPartOfCar item: ${error.message}`,
);
}
}
return item;
}),
);
}
return claimFile;
}
private async populatePartyReplyLinks(partyReply: any) {
if (!partyReply) return;
// --- FIX: Consistently use findById ---
if (partyReply.voice) {
const voiceDoc = await this.blameVoiceDbService.findById(
partyReply.voice.toString(),
);
if (voiceDoc) partyReply.voice = buildFileLink(voiceDoc.path);
}
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) partyReply.documents[docType] = buildFileLink(doc.path);
}
}
}
}
private async populateSignatureLink(comment: any) {
if (comment?.signDetail?.fileId) {
// --- FIX: Consistently use findById ---
const signDoc = await this.userSignDbService.findById(
comment.signDetail.fileId.toString(),
);
if (signDoc)
(comment.signDetail as any).fileUrl = buildFileLink(signDoc.path);
}
}
private async populateIdArray(dbService: any, ids: any[]): Promise<string[]> {
return Promise.all(
ids.map(async (id) => {
if (!id) return id;
if (typeof id === "object" && id.path) {
return buildFileLink(id.path);
}
const idString = id.toString();
if (idString.includes("[object Object]")) {
this.logger.warn(
"Invalid object found in ID array, skipping population.",
id,
);
return id;
}
try {
// --- FIX: Consistently use findById ---
const doc = await dbService.findById(idString);
return doc ? buildFileLink(doc.path) : id;
} catch (error) {
this.logger.error(`Failed to populate ID: ${idString}`, error);
return id;
}
}),
);
} }
async addBranch(clientKey: string, branchDto: CreateBranchDto) { async addBranch(clientKey: string, branchDto: CreateBranchDto) {
const clientId = new Types.ObjectId(clientKey); const clientId = this.getClientId(clientKey);
const existingBranch = await this.branchDbService.findOne({ const existingBranch = await this.branchDbService.findOne({
clientKey: clientId, clientKey: clientId,
@@ -778,19 +381,8 @@ export class ExpertInsurerService {
* - Number of files created in the current month * - Number of files created in the current month
*/ */
async getExpertStatisticsReport(actor: any) { async getExpertStatisticsReport(actor: any) {
const { clientKey } = actor; const clientObjectId = this.getClientId(actor);
if (!clientKey) { const claimFiles = await this.getClientClaimFiles(clientObjectId);
throw new BadRequestException("Client key is required");
}
const clientObjectId = new Types.ObjectId(clientKey);
// Get all claim files for this client
const claimFiles = await this.claimRequestManagementModel
.find({
userClientKey: clientObjectId,
})
.lean();
// Calculate current month date range // Calculate current month date range
const now = new Date(); const now = new Date();
@@ -907,6 +499,6 @@ export class ExpertInsurerService {
} }
async retrieveInsuranceBranches(insuranceId: string) { async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId); return this.branchDbService.findAll(insuranceId);
} }
} }

View File

@@ -0,0 +1,149 @@
import { Types } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */
export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = {
accidentWay: { id: 8, label: "جلو به عقب" },
accidentReason: {
id: 1,
fanavaran: 1,
label:
"عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو",
},
accidentType: {
id: 1,
label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه",
},
} as const;
export type MutualAgreementDecisionPayload = {
guiltyPartyId: Types.ObjectId;
description: string;
decidedAt: Date;
decidedByExpertId?: Types.ObjectId;
fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS;
};
function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string {
return (p?.person?.phoneNumber || "").trim();
}
function partyUserId(
p: { person?: { userId?: Types.ObjectId } } | undefined,
): Types.ObjectId | null {
const id = p?.person?.userId;
if (!id) return null;
return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id));
}
/**
* When the first party chose AGREED (guilty or damaged) and the second party exists,
* derive the guilty party and build the synthetic expert.decision payload.
* Does not set decidedByExpertId — this is not a field-expert adjudication.
*/
export function buildMutualAgreementExpertDecision(
doc: Record<string, unknown>,
): MutualAgreementDecisionPayload | null {
if (doc.type !== BlameRequestType.THIRD_PARTY) return null;
if (doc.blameStatus !== BlameStatus.AGREED) return null;
const parties = (doc.parties ?? []) as Array<{
role?: string;
person?: { userId?: Types.ObjectId; phoneNumber?: string };
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const first = parties.find((p) => p.role === PartyRole.FIRST);
const second = parties.find((p) => p.role === PartyRole.SECOND);
if (!first || !second) return null;
const st = first.statement;
if (!st) return null;
if (st.acceptsExpertOpinion) return null;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return null;
let guiltyParty: typeof first;
let damagedParty: typeof first;
if (admits) {
guiltyParty = first;
damagedParty = second;
} else {
guiltyParty = second;
damagedParty = first;
}
const guiltyId = partyUserId(guiltyParty);
if (!guiltyId) return null;
const gPhone = partyPhone(guiltyParty);
const dPhone = partyPhone(damagedParty);
if (!gPhone || !dPhone) return null;
const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`;
return {
guiltyPartyId: guiltyId,
description,
decidedAt: new Date(),
fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
};
}
/**
* Merge complementary statement flags onto the second party for expert/claim UIs when
* only the first party submitted a formal confession (V2 remote flow).
*/
export function enrichBlamePartiesForAgreementView(
doc: Record<string, unknown>,
): Array<Record<string, unknown>> {
const parties = (doc.parties ?? []) as Array<Record<string, unknown>>;
if (!Array.isArray(parties) || parties.length === 0) return parties;
const first = parties.find((p) => p.role === PartyRole.FIRST) as
| {
statement?: Record<string, unknown>;
}
| undefined;
const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND);
if (!first?.statement || secondIdx === -1) return parties;
if (doc.blameStatus !== BlameStatus.AGREED) return parties;
const st = first.statement as {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
if (st.acceptsExpertOpinion) return parties;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return parties;
const second = parties[secondIdx] as {
statement?: Record<string, unknown>;
};
const prev = (second.statement ?? {}) as Record<string, unknown>;
const mergedStatement = {
...prev,
admitsGuilt: admits ? false : true,
claimsDamage: admits ? true : false,
acceptsExpertOpinion: false,
};
const next = [...parties];
next[secondIdx] = {
...second,
statement: mergedStatement,
};
return next;
}

View File

@@ -0,0 +1,105 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
export function resendRequestHasPayload(r: {
resendDescription?: string;
resendDocuments?: unknown[];
resendCarParts?: unknown[];
}): boolean {
if (!r) return false;
if (String(r.resendDescription || "").trim() !== "") return true;
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
return false;
}
/** Normalize expert-requested document keys (strings or { key }). */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
if (!Array.isArray(docs)) return [];
const keys: string[] = [];
for (const d of docs) {
if (typeof d === "string" && d.trim()) keys.push(d.trim());
else if (d && typeof d === "object" && "key" in (d as object)) {
const k = String((d as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
}
/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
if (!Array.isArray(parts)) return [];
const keys: string[] = [];
for (const p of parts) {
if (typeof p === "string" && p.trim()) keys.push(p.trim());
else if (p && typeof p === "object" && "key" in (p as object)) {
const k = String((p as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
}
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
const rd = claim?.requiredDocuments;
if (!rd) return undefined;
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
}
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean {
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined;
return !!doc?.uploaded;
}
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts;
if (!data) return false;
const cap = data instanceof Map ? data.get(partKey) : data[partKey];
return !!cap;
}
/**
* True when the user has satisfied every document and part the damage expert asked for,
* or when the expert only left instructions (no doc/part list) and there is a description.
*/
export function canFinalizeExpertResend(claim: any): boolean {
if (!claim) return false;
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false;
const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
const partKeys = normalizeResendPartKeys(r.resendCarParts);
if (docKeys.length === 0 && partKeys.length === 0) {
return String(r.resendDescription || "").trim() !== "";
}
for (const k of docKeys) {
if (!isRequiredDocumentUploaded(claim, k)) return false;
}
for (const k of partKeys) {
if (!hasDamagedPartCapture(claim, k)) return false;
}
return true;
}
export function documentKeyAllowedForExpertResend(
claim: any,
documentKey: string,
): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
return allowed.has(documentKey);
}
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
return allowed.has(partKey);
}

View File

@@ -0,0 +1,49 @@
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
CaseStatus.OPEN,
CaseStatus.WAITING_FOR_SECOND_PARTY,
]);
/**
* Claim: user flow before submission is complete (analog to `ClaimWorkflowStep.USER_SUBMISSION_COMPLETE`).
*/
const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
ClaimCaseStatus.CREATED,
ClaimCaseStatus.SELECTING_OUTER_PARTS,
ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
]);
export function blameCaseStatusToReportBucket(status: string): string {
if (BLAME_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
return status;
}
export function claimCaseStatusToReportBucket(status: string): string {
if (CLAIM_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
return status;
}
/** Keys returned for blame V2 expert report (IN_PROGRESS + each native post-early status). */
export function initialBlameExpertReportBuckets(): Record<string, number> {
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
for (const s of Object.values(CaseStatus)) {
if (BLAME_IN_PROGRESS_STATUSES.has(s)) continue;
out[s] = 0;
}
return out;
}
/** Keys returned for claim V2 expert report. */
export function initialClaimExpertReportBuckets(): Record<string, number> {
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
for (const s of Object.values(ClaimCaseStatus)) {
if (CLAIM_IN_PROGRESS_STATUSES.has(s)) continue;
out[s] = 0;
}
return out;
}

View File

@@ -0,0 +1,29 @@
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { ExpertProfileSnapshot } from "src/request-management/entities/schema/expert-profile-snapshot.schema";
export function snapshotFromFieldExpert(
doc: ExpertModel | null | undefined,
): ExpertProfileSnapshot | undefined {
if (!doc) return undefined;
return {
firstName: doc.firstName ?? undefined,
lastName: doc.lastName ?? undefined,
email: doc.email ?? undefined,
insuActivityCo: doc.insuActivityCo ?? undefined,
mobile: (doc.mobile ?? doc.phone) ?? undefined,
};
}
export function snapshotFromDamageExpert(
doc: DamageExpertModel | null | undefined,
): ExpertProfileSnapshot | undefined {
if (!doc) return undefined;
return {
firstName: doc.firstName ?? undefined,
lastName: doc.lastName ?? undefined,
email: doc.email ?? undefined,
insuActivityCo: doc.insuActivityCo ?? undefined,
mobile: doc.mobile ?? undefined,
};
}

View File

@@ -0,0 +1,115 @@
import { Connection, Query, Schema } from "mongoose";
const PERSIAN_TO_ENGLISH: Record<string, string> = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
};
function normalizeDigits(value: string): string {
return value.replace(/[۰-۹]/g, (ch) => PERSIAN_TO_ENGLISH[ch] ?? ch);
}
function toIranFaDateTimeTuple(input: Date): [string, string] {
const dateFa = normalizeDigits(
input.toLocaleDateString("fa-IR", {
timeZone: "Asia/Tehran",
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
);
const timeFa = normalizeDigits(
input.toLocaleTimeString("fa-IR", {
timeZone: "Asia/Tehran",
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
);
return [dateFa, timeFa];
}
function isMissingFaTuple(value: unknown): boolean {
if (value == null) return true;
if (!Array.isArray(value)) return false;
if (value.length < 2) return true;
const [d, t] = value;
return !d || !t;
}
function writeFaTimestampsOnUpdate(query: Query<any, any>): void {
const nowFa = toIranFaDateTimeTuple(new Date());
const update = (query.getUpdate() ?? {}) as Record<string, any>;
if (!update.$set || typeof update.$set !== "object") {
update.$set = {};
}
update.$set.updatedAtFa = nowFa;
// Keep createdAtFa immutable on regular updates; set only for upsert inserts.
const opts = (query as any).getOptions?.() ?? {};
if (opts.upsert) {
if (!update.$setOnInsert || typeof update.$setOnInsert !== "object") {
update.$setOnInsert = {};
}
if (isMissingFaTuple(update.$setOnInsert.createdAtFa)) {
update.$setOnInsert.createdAtFa = nowFa;
}
}
query.setUpdate(update);
}
/**
* Adds `createdAtFa` / `updatedAtFa` (Iran timezone tuple: [date, time])
* to all schemas that use mongoose timestamps.
*/
export function applyIranFaTimestampPlugin(connection: Connection): void {
connection.plugin((schema: Schema) => {
const timestamps = schema.get("timestamps");
if (!timestamps) return;
schema.add({
createdAtFa: { type: [String], index: true },
updatedAtFa: { type: [String], index: true },
});
schema.pre("save", function (next) {
const nowFa = toIranFaDateTimeTuple(new Date());
if (isMissingFaTuple((this as any).createdAtFa)) {
(this as any).createdAtFa = nowFa;
}
(this as any).updatedAtFa = nowFa;
next();
});
schema.pre("insertMany", function (next, docs: any[]) {
const nowFa = toIranFaDateTimeTuple(new Date());
for (const doc of docs) {
if (isMissingFaTuple(doc.createdAtFa)) doc.createdAtFa = nowFa;
doc.updatedAtFa = nowFa;
}
next();
});
schema.pre("findOneAndUpdate", function () {
writeFaTimestampsOnUpdate(this);
});
schema.pre("updateOne", function () {
writeFaTimestampsOnUpdate(this);
});
schema.pre("updateMany", function () {
writeFaTimestampsOnUpdate(this);
});
});
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
export function requireActorClientKey(actor: { clientKey?: string }): string {
const ck = actor?.clientKey;
if (!ck || typeof ck !== "string") {
throw new BadRequestException(
"Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.",
);
}
return ck;
}
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
return (doc?.parties ?? []).some(
(p: any) => String(p?.person?.clientId ?? "") === id,
);
}
export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
const owner = doc?.owner as
| { clientId?: unknown; userClientKey?: unknown }
| undefined;
const tenantId = owner?.clientId ?? owner?.userClientKey;
return String(tenantId ?? "") === id;
}
/**
* Field expertinitiated cases may not yet have party clientId filled; the
* initiating expert is always scoped to their insurer via JWT.
*/
export function blameCaseAccessibleToExpert(
doc: any,
actor: { sub: string; clientKey?: string },
): boolean {
if (
doc.expertInitiated &&
doc.initiatedByFieldExpertId &&
String(doc.initiatedByFieldExpertId) === actor.sub
) {
return true;
}
const ck = actor?.clientKey;
if (!ck) return false;
return blameCaseTouchesClient(doc, ck);
}
export function assertBlameCaseForExpertTenant(
doc: any,
actor: { sub: string; clientKey?: string },
): void {
if (!blameCaseAccessibleToExpert(doc, actor)) {
throw new ForbiddenException(
"This blame case does not belong to your organization.",
);
}
}
export function assertClaimCaseForTenant(
doc: any,
actor: { clientKey?: string },
): void {
const ck = requireActorClientKey(actor);
if (!claimCaseTouchesClient(doc, ck)) {
throw new ForbiddenException(
"This claim does not belong to your organization.",
);
}
}

View File

@@ -1,401 +1,45 @@
import { import { Injectable, Logger, NotFoundException } from "@nestjs/common";
BadGatewayException,
Injectable,
Logger,
} from "@nestjs/common";
import axios from "axios";
import { LookupDbService } from "./entities/db-service/lookup.db.service"; import { LookupDbService } from "./entities/db-service/lookup.db.service";
@Injectable() @Injectable()
export class LookupsService { export class LookupsService {
private readonly logger = new Logger(LookupsService.name); private readonly logger = new Logger(LookupsService.name);
private readonly GET_APP_TOKEN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
private readonly LOGIN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
private readonly BASE_API_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
private readonly BASE_INFO_PATH = "base-info";
private readonly CODE_LIST_PATH = "code-list";
// Authentication credentials constructor(private readonly lookupDbService: LookupDbService) {}
private readonly APP_NAME = "fanhab";
private readonly SECRET = "5Fa@N#A2B";
private readonly USERNAME = "fanhabUser";
private readonly PASSWORD = "Fan#@2U$3er";
// API headers
private readonly CORP_ID = "3539";
private readonly CONTRACT_ID = "263";
private readonly LOCATION = "100";
constructor(
private readonly lookupDbService: LookupDbService,
) {}
/** /**
* Step 1: Get appToken from GetAppToken API * Returns stored `response` for a lookup document by `name` in the lookups collection.
* The token is returned in the response header 'appToken'
*/ */
private async getAppToken(): Promise<string> { async getLookup(lookupName: string): Promise<any> {
try { const doc = await this.lookupDbService.findOne({ name: lookupName });
const requestHeaders: any = { if (!doc) {
appname: this.APP_NAME, this.logger.warn(`Lookup not found in database: ${lookupName}`);
secret: this.SECRET, throw new NotFoundException(`Lookup "${lookupName}" not found`);
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
delete requestHeaders["Content-Type"];
const response = await axios({
method: "POST",
url: this.GET_APP_TOKEN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
} }
return data; return doc.response;
},
],
});
// Axios lowercases headers, so check both cases
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
this.logger.error("Failed to get appToken from response headers");
this.logger.error("Response status:", response.status);
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
this.logger.error("Available header keys:", Object.keys(response.headers));
this.logger.error("Response data:", response.data);
// Extract error message from response if available
const errorMessage = response.data?.Message ||
response.data?.message ||
response.data ||
"Failed to get appToken from response headers";
throw new BadGatewayException(errorMessage);
} }
this.logger.log(`Successfully obtained appToken: ${appToken}`);
return appToken;
} catch (error) {
this.logger.error("Failed to get appToken");
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", error.response?.data);
this.logger.error("Error response headers:", error.response?.headers);
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request headers:", error.config?.headers);
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to get appToken from external API";
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException("Failed to get appToken from external API");
}
}
}
/**
* Step 2: Login to get authenticationToken
* The token is returned in the response body field 'authenticationtoken'
*/
private async login(appToken: string): Promise<string> {
try {
this.logger.log(`Attempting login with appToken: ${appToken}`);
this.logger.log(`Login URL: ${this.LOGIN_URL}`);
this.logger.log(`Username: ${this.USERNAME}`);
const requestHeaders: any = {
appToken: appToken,
userName: this.USERNAME,
password: this.PASSWORD,
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
// Axios adds it automatically, but server doesn't expect it for empty body
delete requestHeaders["Content-Type"];
this.logger.log("Request headers (without password):", {
appToken: appToken,
userName: this.USERNAME,
password: "***",
"Content-Length": "0",
"Content-Type": "removed",
});
const response = await axios({
method: "POST",
url: this.LOGIN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
this.logger.log("Login response status:", response.status);
this.logger.log("Login response data:", JSON.stringify(response.data, null, 2));
this.logger.log("Login response headers:", JSON.stringify(response.headers, null, 2));
// Check response headers first (authenticationToken is in headers, not body)
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
// Fallback to body if not in headers
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
this.logger.log(`Extracted authenticationToken: ${authenticationToken ? 'Found' : 'Not found'}`);
if (!authenticationToken) {
this.logger.error("Failed to get authenticationToken from response");
this.logger.error("Response status:", response.status);
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
this.logger.error("Response headers keys:", Object.keys(response.headers || {}));
this.logger.error("Response data:", JSON.stringify(response.data, null, 2));
this.logger.error("Response data keys:", Object.keys(response.data || {}));
// Extract error message from response if available
const errorMessage = response.data?.Message ||
response.data?.message ||
"Failed to get authenticationToken from response";
throw new BadGatewayException(errorMessage);
}
this.logger.log(`Successfully obtained authenticationToken: ${authenticationToken}`);
return authenticationToken;
} catch (error) {
this.logger.error("Failed to login");
this.logger.error("AppToken used:", appToken);
this.logger.error("Login URL:", this.LOGIN_URL);
this.logger.error("Username:", this.USERNAME);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request method:", error.config?.method);
this.logger.error("Request headers:", JSON.stringify({
...error.config?.headers,
password: "***",
}, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to login to external API";
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException("Failed to login to external API");
}
}
}
/**
* Step 3: Call the lookup API with authenticationToken
*/
private async callLookupApi(
lookupName: string,
authenticationToken: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
try {
const response = await axios.get(url, {
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
});
this.logger.log(`Successfully fetched data for lookup: ${lookupName}`);
return response.data;
} catch (error) {
this.logger.error(`Failed to fetch lookup data for ${lookupName}`);
this.logger.error("URL:", url);
this.logger.error("AuthenticationToken used:", authenticationToken);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request headers:", JSON.stringify(error.config?.headers, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
`Failed to fetch lookup data for ${lookupName}`;
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException(
`Failed to fetch lookup data for ${lookupName}`,
);
}
}
}
/**
* Complete authentication flow and fetch lookup data
*/
private async fetchLookupDataFromExternalApi(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<{ authenticationToken: string; response: any; url: string }> {
// Step 1: Get appToken
const appToken = await this.getAppToken();
// Step 2: Login to get authenticationToken
const authenticationToken = await this.login(appToken);
// Step 3: Call the lookup API
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
const response = await this.callLookupApi(
lookupName,
authenticationToken,
pathType,
);
return { authenticationToken, response, url };
}
/**
* Check if lookup data needs to be updated (once a month)
*/
private shouldUpdateLookup(updatedAt: Date | null): boolean {
if (!updatedAt) {
return true; // Never updated, need to fetch
}
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
return updatedAt < oneMonthAgo;
}
/**
* Get lookup data by name
* Returns cached data if available and not expired, otherwise fetches from external API
*/
async getLookup(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
// Check if we have cached data
const existingLookup = await this.lookupDbService.findOne({
name: lookupName,
});
if (existingLookup && !this.shouldUpdateLookup(existingLookup.updatedAt)) {
this.logger.log(
`Returning cached data for lookup: ${lookupName} (last updated: ${existingLookup.updatedAt})`,
);
return existingLookup.response;
}
// Need to fetch from external API
this.logger.log(`Fetching fresh data for lookup: ${lookupName}`);
const { authenticationToken, response, url } =
await this.fetchLookupDataFromExternalApi(lookupName, pathType);
// Save or update in database
await this.lookupDbService.findOneAndUpdate(
{ name: lookupName },
{
url: url,
authenticationToken: authenticationToken,
name: lookupName,
response: response,
updatedAt: new Date(),
},
);
this.logger.log(`Successfully saved/updated lookup data for: ${lookupName}`);
return response;
}
/**
* Get accident-causes lookup data
*/
async getAccidentCauses(): Promise<any> { async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes", "base-info"); return await this.getLookup("accident-causes");
} }
/**
* Get accident-report-type lookup data
*/
async getAccidentReportType(): Promise<any> { async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type", "code-list"); return await this.getLookup("accident-report-type");
} }
/**
* Get vehicle-use-types lookup data
*/
async getVehicleUseTypes(): Promise<any> { async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types", "base-info"); return await this.getLookup("vehicle-use-types");
} }
/**
* Get dmg-pay-method lookup data
*/
async getDmgPayMethod(): Promise<any> { async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method", "code-list"); return await this.getLookup("dmg-pay-method");
} }
/**
* Get driving-licence-types lookup data
*/
async getDrivingLicenceTypes(): Promise<any> { async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types", "base-info"); return await this.getLookup("driving-licence-types");
} }
/**
* Get accident-culprit-type lookup data
*/
async getAccidentCulpritType(): Promise<any> { async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type", "code-list"); return await this.getLookup("accident-culprit-type");
} }
} }

View File

@@ -1,50 +1,28 @@
export class DamageExpertAllRequestsCountReportDtoRs { /**
all: number; * Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
UnChecked: number; * plus `all` = total matching documents for the tenant.
CheckedRequest: number; */
CheckAgain: number; export class BlameCaseStatusCountReportDtoRs {
WaitingForUserToResend: number; [key: string]: number;
CloseRequest: number;
WaitingForUserCompleted: number;
InPersonVisit: number;
constructor(report: any) { constructor(report: Record<string, number>) {
this.all = report.all; Object.assign(this, report);
this.UnChecked = report.UnChecked;
this.CheckedRequest = report.CheckedRequest;
this.CheckAgain = report.CheckAgain;
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
this.InPersonVisit = report.InPersonVisit;
} }
} }
export class ExpertAllRequestsCountReportDtoRs { export class ClaimCaseStatusCountReportDtoRs {
all: number; [key: string]: number;
UnChecked: number;
CheckedRequest: number;
CheckAgain: number;
WaitingForUserToResend: number;
CloseRequest: number;
WaitingForUserCompleted: number;
constructor(report: any) { constructor(report: Record<string, number>) {
this.all = report.all; Object.assign(this, report);
this.UnChecked = report.UnChecked;
this.CheckedRequest = report.CheckedRequest;
this.CheckAgain = report.CheckAgain;
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
} }
} }
export class CompanyAllRequestsCountReportDtoRs { export class CompanyAllRequestsCountReportDtoRs {
blame: object; blame: Record<string, number>;
claim: object; claim: Record<string, number>;
constructor(blame: object, claim: object) { constructor(blame: Record<string, number>, claim: Record<string, number>) {
this.blame = blame; this.blame = blame;
this.claim = claim; this.claim = claim;
} }

View File

@@ -1,7 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { ClientModule } from "src/client/client.module";
import { ReportsController } from "./reports.controller"; import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
@@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service";
imports: [ imports: [
RequestManagementModule, RequestManagementModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
ClientModule,
], ],
controllers: [ReportsController], controllers: [ReportsController],
providers: [ReportsService], providers: [ReportsService],

View File

@@ -1,392 +1,231 @@
import { Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum"; import { requireActorClientKey } from "src/helpers/tenant-scope";
import { UserType } from "src/Types&Enums/userType.enum";
import { import {
BlameCaseStatusCountReportDtoRs,
ClaimCaseStatusCountReportDtoRs,
CompanyAllRequestsCountReportDtoRs, CompanyAllRequestsCountReportDtoRs,
DamageExpertAllRequestsCountReportDtoRs,
ExpertAllRequestsCountReportDtoRs,
} from "./dto/reports.dto"; } from "./dto/reports.dto";
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
constructor( constructor(
private readonly requestManagementDbService: RequestManagementDbService, private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly clientDbService: ClientDbService,
) {} ) {}
async getAllCheckedRequestsCountFn(role: string, client: string) { private clientObjectId(client: string | undefined): Types.ObjectId {
const statuses = ["UnChecked", "CheckedRequest"]; const ck = requireActorClientKey({ clientKey: client });
const data: Record<string, number> = { all: 0 }; return new Types.ObjectId(ck);
const clientId = new Types.ObjectId(client);
for (const status of statuses) {
let count = 0;
if (role === "damage_expert") {
count = await this.claimRequestManagementDbService.countByFilter({
claimStatus: status,
userClientKey: clientId,
});
} else {
count = await this.requestManagementDbService.countByFilter({
blameStatus: status,
$or: [
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
],
});
}
data[status] = count;
data.all += count;
}
return data;
} }
async getDateFilteredRequestByStatus( private isBlameForClient(r: any, client: Types.ObjectId): boolean {
role: string, const idStr = String(client);
client: string, return (r?.parties || []).some(
start: Date, (p) => String(p?.person?.clientId || "") === idStr,
end: Date,
) {
const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"];
const data: Record<string, number> = {};
const clientId = new Types.ObjectId(client);
for (const status of statuses) {
let blameCount = 0;
let claimCount = 0;
if (role === "expert" || role === "company") {
blameCount = await this.requestManagementDbService.countByFilter({
blameStatus: status,
createdAt: { $gte: start, $lte: end },
$or: [
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
],
});
}
if (role === "damage_expert" || role === "company") {
claimCount = await this.claimRequestManagementDbService.countByFilter({
claimStatus: status,
userClientKey: clientId,
createdAt: { $gte: start, $lte: end },
});
}
data[status] = blameCount + claimCount;
}
return data;
}
private isVisibleToClientType(client: any, actor: any): boolean {
if (actor.userType === UserType.GENUINE) {
return true;
}
if (
actor.userType === UserType.LEGAL &&
String(client._id) === actor.clientKey
) {
return true;
}
return false;
}
private wasHandledByActor(request: any, actorSub: string): boolean {
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
if (!Array.isArray(actorChecker)) {
return false;
}
const matchingEntry = actorChecker.find(
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
); );
return !!matchingEntry;
} }
/** private isClaimForClient(r: any, client: Types.ObjectId): boolean {
* Filters claim requests using the same logic as expert-claim service return String(r?.owner?.userClientKey || "") === String(client);
* to ensure consistency between endpoints
*/
private async filterClaimRequestsForExpert(
requests: any[],
actor: any,
): Promise<any[]> {
const filteredRequests = [];
for (const r of requests) {
// For expert-initiated blame files, only show to the initiating expert
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
if (String(r.blameFile.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated claim files are always visible to the initiating expert
filteredRequests.push(r);
continue;
} }
const client = await this.clientDbService.findOne({ private countBlameByCaseStatus(
_id: r.userClientKey, blames: any[],
}); clientId: Types.ObjectId,
): Record<string, number> {
if (!client) { const out: Record<string, number> = { all: 0 };
this.logger.warn( for (const s of Object.values(CaseStatus)) {
`Client not found for claim request with ID: ${r._id}. Skipping.`, out[s] = 0;
); }
continue; for (const r of blames) {
if (!this.isBlameForClient(r, clientId)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
} }
const specialHandlingStatuses = [ private countClaimByCaseStatus(
ReqClaimStatus.CheckAgain, claims: any[],
ReqClaimStatus.ReviewRequest, clientId: Types.ObjectId,
ReqClaimStatus.PendingFactorValidation, ): Record<string, number> {
]; const out: Record<string, number> = { all: 0 };
for (const s of Object.values(ClaimCaseStatus)) {
const requiresSpecificActorCheck = specialHandlingStatuses.includes( out[s] = 0;
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
} }
for (const r of claims) {
if (!this.isClaimForClient(r, clientId)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
} }
return out;
} }
return filteredRequests; async getAllRequestsCountByRole(role: string, client: string | undefined) {
} const clientId = this.clientObjectId(client);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
async getAllRequestsCountByRole(role: string, client: string, actor?: any) {
if (role === "expert") { if (role === "expert") {
const statuses = Object.values(ReqBlameStatus); return this.countBlameByCaseStatus(blames as any[], clientId);
const data: Record<string, number> = { all: 0 };
for (const status of statuses) {
const filter = {
blameStatus: status,
$or: [
{
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
client,
),
},
// {
// "secondPartyDetails.secondPartyClient.clientId":
// new Types.ObjectId(client),
// },
],
};
const count =
await this.requestManagementDbService.countByFilter(filter);
data[status] = count;
data.all += count;
}
return data;
} }
if (role === "damage_expert") { if (role === "damage_expert") {
const statuses = Object.values(ReqClaimStatus); return this.countClaimByCaseStatus(claims as any[], clientId);
const data: Record<string, number> = { all: 0 };
// For damage_expert, we need to apply the same filtering as expert-claim service
if (actor) {
// Fetch all requests with the statuses that expert-claim service shows
// This matches the statuses in getClaimRequestsListForExpert
const relevantStatuses = [
ReqClaimStatus.UnChecked,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
];
// Fetch all requests with relevant statuses (matching expert-claim query)
const allRequests =
await this.claimRequestManagementDbService.findAllByStatus({
claimStatus: { $in: relevantStatuses },
});
// Filter requests using the same logic as expert-claim service
const filteredRequests =
await this.filterClaimRequestsForExpert(allRequests, actor);
// Count by status from filtered results
for (const status of statuses) {
const count = filteredRequests.filter(
(r) => r.claimStatus === status,
).length;
data[status] = count;
data.all += count;
}
} else {
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
for (const status of statuses) {
const filter = {
claimStatus: status,
userClientKey: new Types.ObjectId(client),
};
const count =
await this.claimRequestManagementDbService.countByFilter(filter);
data[status] = count;
data.all += count;
}
} }
return data;
}
// ✅ Company logic
if (role === "company") { if (role === "company") {
const blameStatuses = Object.values(ReqBlameStatus); return {
const claimStatuses = Object.values(ReqClaimStatus); blame: this.countBlameByCaseStatus(blames as any[], clientId),
claim: this.countClaimByCaseStatus(claims as any[], clientId),
const blameData: Record<string, number> = { all: 0 };
const claimData: Record<string, number> = { all: 0 };
// Only count files where this company is FIRST party in Blame
for (const status of blameStatuses) {
const filter = {
blameStatus: status,
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
client,
),
}; };
const count =
await this.requestManagementDbService.countByFilter(filter);
blameData[status] = count;
blameData.all += count;
}
// Claims always use `userClientKey`
for (const status of claimStatuses) {
const filter = {
claimStatus: status,
userClientKey: new Types.ObjectId(client),
};
const count =
await this.claimRequestManagementDbService.countByFilter(filter);
claimData[status] = count;
claimData.all += count;
}
return { blameData, claimData };
} }
return null; return null;
} }
async getAllRequestsReportCount(actor, client) { /**
if (actor.role === "damage_expert") { * Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
// Pass actor to apply filtering logic for damage_expert * Company gets both blame and claim maps; expert / damage_expert get one map.
const data = await this.getAllRequestsCountByRole( */
actor.role, async getDateFilteredRequestByStatus(
client, role: string,
actor, client: string | undefined,
); start: Date,
return new DamageExpertAllRequestsCountReportDtoRs(data); end: Date,
} else if (actor.role === "expert") { ) {
const data = await this.getAllRequestsCountByRole(actor.role, client); const clientId = this.clientObjectId(client);
return new ExpertAllRequestsCountReportDtoRs(data); const [blames, claims] = await Promise.all([
} else { this.blameRequestDbService.find({}, { lean: true }),
// company this.claimCaseDbService.find({}, { lean: true }),
const damageExpertData = await this.getAllRequestsCountByRole( ]);
"damage_expert",
client, const inRange = (r: any) => {
); const createdAt = new Date(r.createdAt);
const expertData = await this.getAllRequestsCountByRole("expert", client); return createdAt >= start && createdAt <= end;
return new CompanyAllRequestsCountReportDtoRs( };
expertData,
damageExpertData, if (role === "company") {
); const blameOut: Record<string, number> = { all: 0 };
const claimOut: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) blameOut[s] = 0;
for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0;
for (const r of blames as any[]) {
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in blameOut) blameOut[st]++;
else blameOut[st] = (blameOut[st] ?? 0) + 1;
blameOut.all++;
} }
for (const r of claims as any[]) {
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in claimOut) claimOut[st]++;
else claimOut[st] = (claimOut[st] ?? 0) + 1;
claimOut.all++;
}
return { blame: blameOut, claim: claimOut };
} }
async getAllRequestsReportPerMonth(actor, client) { if (role === "expert") {
const result = []; const out: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) out[s] = 0;
for (const r of blames as any[]) {
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
}
if (role === "damage_expert") {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(ClaimCaseStatus)) out[s] = 0;
for (const r of claims as any[]) {
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
}
throw new BadRequestException("Unsupported role for reports");
}
async getAllRequestsReportCount(actor: any, client: string | undefined) {
requireActorClientKey(actor);
if (actor.role === "damage_expert") {
const data = await this.getAllRequestsCountByRole(actor.role, client);
return new ClaimCaseStatusCountReportDtoRs(data as Record<string, number>);
}
if (actor.role === "expert") {
const data = await this.getAllRequestsCountByRole(actor.role, client);
return new BlameCaseStatusCountReportDtoRs(data as Record<string, number>);
}
const companyPayload = await this.getAllRequestsCountByRole(
"company",
client,
);
return new CompanyAllRequestsCountReportDtoRs(
(companyPayload as { blame: Record<string, number> }).blame,
(companyPayload as { claim: Record<string, number> }).claim,
);
}
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
requireActorClientKey(actor);
const result: any[] = [];
const now = new Date(); const now = new Date();
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
let data: any;
if (actor.role === "company") { if (actor.role === "company") {
for (let i = 0; i < 5; i++) { data = await this.getDateFilteredRequestByStatus(
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); "company",
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const blameData = await this.getDateFilteredRequestByStatus(
"expert",
client, client,
monthStart, monthStart,
monthEnd, monthEnd,
); );
const claimData = await this.getDateFilteredRequestByStatus(
"damage_expert",
client,
monthStart,
monthEnd,
);
result.unshift({
stDate: monthStart,
enDate: monthEnd,
faLabel: label,
data: {
blame: blameData,
claim: claimData,
},
});
}
return result;
} else { } else {
for (let i = 0; i < 5; i++) { data = await this.getDateFilteredRequestByStatus(
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const data = await this.getDateFilteredRequestByStatus(
actor.role, actor.role,
client, client,
monthStart, monthStart,
monthEnd, monthEnd,
); );
}
result.unshift({ result.unshift({
stDate: monthStart, stDate: monthStart,
enDate: monthEnd, enDate: monthEnd,
@@ -396,18 +235,9 @@ export class ReportsService {
} }
return result; return result;
} }
}
async getAllCheckedRequestsCount(actor, client) { /** Same aggregates as `GET /reports/report/requests` (native status keys). */
if (actor.role === "damage_expert" || actor.role === "expert") { async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
return this.getAllCheckedRequestsCountFn(actor.role, client); return this.getAllRequestsReportCount(actor, client);
} else {
const blame = await this.getAllCheckedRequestsCountFn("expert", client);
const claim = await this.getAllCheckedRequestsCountFn(
"damage_expert",
client,
);
return { blame, claim };
}
} }
} }

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
/**
* Computed on GET blame v2 for the current viewer — no DB field on BlameRequest.
*/
export class ClaimCreationHintDto {
@ApiProperty({
description:
"True if a v2 claim case already exists for this blame (by blameRequestId).",
})
hasClaim: boolean;
@ApiProperty({
description:
"True when this viewer is the damaged party, blame is COMPLETED, no claim exists yet, " +
"and for THIRD_PARTY all parties have signed and accepted the expert decision.",
})
shouldGuideToCreateClaim: boolean;
}

View File

@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
} }
export class DescriptionDto { export class DescriptionDto {
@ApiProperty() @ApiProperty({ description: "Accident description (required for all types)" })
desc: string; desc: string;
// CAR_BODY specific fields
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Date of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "2025-12-08" example: "2025-12-08",
}) })
accidentDate?: Date; accidentDate?: Date;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "14:30" example: "14:30",
}) })
accidentTime?: string; accidentTime?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: WeatherCondition, enum: WeatherCondition,
description: "Weather condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: WeatherCondition.CLEAR example: WeatherCondition.CLEAR,
}) })
weatherCondition?: WeatherCondition; weatherCondition?: WeatherCondition;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: RoadCondition, enum: RoadCondition,
description: "Road condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: RoadCondition.DRY example: RoadCondition.DRY,
}) })
roadCondition?: RoadCondition; roadCondition?: RoadCondition;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: LightCondition, enum: LightCondition,
description: "Light condition at time of accident (for CAR_BODY type only)", description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: LightCondition.DAYLIGHT example: LightCondition.DAYLIGHT,
}) })
lightCondition?: LightCondition; lightCondition?: LightCondition;
} }
/**
* THIRD_PARTY description step: only desc is accepted.
*/
export class ThirdPartyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
}
/**
* CAR_BODY description step: desc + accident date/time and conditions required.
*/
export class CarBodyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
@ApiProperty({ description: "Date of accident", example: "2025-12-08" })
accidentDate: Date;
@ApiProperty({ description: "Time of accident", example: "14:30" })
accidentTime: string;
@ApiProperty({ enum: WeatherCondition, example: WeatherCondition.CLEAR })
weatherCondition: WeatherCondition;
@ApiProperty({ enum: RoadCondition, example: RoadCondition.DRY })
roadCondition: RoadCondition;
@ApiProperty({ enum: LightCondition, example: LightCondition.DAYLIGHT })
lightCondition: LightCondition;
}
export class FirstPartyDetail { export class FirstPartyDetail {
// @ApiProperty({ type: String }) // @ApiProperty({ type: String })
firstPartyId?: Types.ObjectId; firstPartyId?: Types.ObjectId;

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto"; import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto; firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto; firstPartyDescription: DescriptionDto;
} }

View File

@@ -0,0 +1,30 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import {
CarBodyFormDto,
DescriptionDto,
InitialFormDto,
} from "./create-request-management.dto";
/**
* V2 (IN_PERSON): single expert description/location for the scene.
*/
export class ExpertCompleteCarBodyFormV2Dto {
@ApiProperty({
description: "First party phone number",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiProperty({ type: InitialFormDto })
firstPartyInitialForm: InitialFormDto;
@ApiProperty({ type: CarBodyFormDto })
carBodyForm: CarBodyFormDto;
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: DescriptionDto })
expertDescription: DescriptionDto;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { LocationDto } from "./create-request-management.dto";
/**
* V2 (IN_PERSON): one scene location provided by expert.
*/
export class ExpertCompleteLocationV2Dto {
@ApiProperty({ type: LocationDto })
location: LocationDto;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { LocationDto } from "./create-request-management.dto";
export class ExpertCompleteLocationsDto {
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiPropertyOptional({ type: LocationDto })
secondPartyLocation?: LocationDto;
}

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto } from "./create-request-management.dto"; import { InitialFormDto } from "./create-request-management.dto";
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
plate: AddPlateDto; plate: AddPlateDto;
@ApiProperty({ type: LocationDto })
location: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
description: DescriptionDto; description: DescriptionDto;
} }
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
@ApiProperty({ type: AddPlateDto }) @ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto; firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto }) @ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto; firstPartyDescription: DescriptionDto;

View File

@@ -0,0 +1,50 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { DescriptionDto, InitialFormDto } from "./create-request-management.dto";
/**
* V2 (IN_PERSON): second party inputs without separate description/location.
*/
export class ExpertSecondPartyInfoV2Dto {
@ApiProperty({
description: "Second party phone number",
example: "09123456789",
})
phoneNumber: string;
@ApiProperty({ type: InitialFormDto })
initialForm: InitialFormDto;
@ApiProperty({ type: AddPlateDto })
plate: AddPlateDto;
}
/**
* V2 (IN_PERSON): single expert description shared for the whole scene.
*/
export class ExpertCompleteThirdPartyFormV2Dto {
@ApiProperty({
description: "First party phone number",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiProperty({ type: InitialFormDto })
firstPartyInitialForm: InitialFormDto;
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: ExpertSecondPartyInfoV2Dto })
secondParty: ExpertSecondPartyInfoV2Dto;
@ApiProperty({ type: DescriptionDto })
expertDescription: DescriptionDto;
@ApiProperty({
description:
"Phone number of the guilty party. Must match either first or second party phone number.",
example: "09123456789",
})
guiltyPartyPhoneNumber: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
/**
* Body for field expert uploading a party's signature for expert-initiated IN_PERSON blame.
*/
export class ExpertUploadPartySignatureDto {
@ApiProperty({
enum: ["FIRST", "SECOND"],
description: "Which party's signature is being uploaded. CAR_BODY: use FIRST only. THIRD_PARTY: FIRST and SECOND.",
})
partyRole: "FIRST" | "SECOND";
@ApiProperty({
default: true,
description: "Party accepts the blame agreement (true). Set false if party rejects.",
})
isAccept: boolean;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString } from "class-validator";
export class CreateRegistrarInitiatedFileDto {
@ApiProperty({ enum: ["THIRD_PARTY", "CAR_BODY"], example: "THIRD_PARTY" })
@IsString()
@IsIn(["THIRD_PARTY", "CAR_BODY"])
type: "THIRD_PARTY" | "CAR_BODY";
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
export class SendExpertInitiatedLinkV2Dto {
@ApiProperty({
description:
"Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty",
example: "requestManagement/firstParty",
})
@IsString()
@IsNotEmpty()
frontendRoute: string;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
/**
* Body for field expert to request OTP send to one or two parties for expert-initiated IN_PERSON blame.
* Uses the same SMS flow as /user/send-otp. After this, parties receive SMS with OTP; expert collects and calls verify-party-otps.
*/
export class SendPartyOtpsDto {
@ApiProperty({
description: "First party phone number (e.g. 09123456789). SMS with OTP will be sent to this number.",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiPropertyOptional({
description: "Second party phone number. Required when blame type is THIRD_PARTY. SMS with OTP will be sent to this number.",
example: "09187654321",
})
secondPartyPhoneNumber?: string;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class UserResendResponseDto { export class UserResendResponseDto {
@ApiProperty({ @ApiProperty({
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
description: "Whether the user has completed uploading all requested items", description: "Whether the user has completed uploading all requested items",
}) })
completed: boolean; completed: boolean;
@ApiPropertyOptional({
description:
"Per-item UI hint: document_camera (open camera), voice, video, or text (multiline field).",
example: [
{ item: "drivingLicense", inputKind: "document_camera" },
{ item: "description", inputKind: "text" },
],
})
requestedItemsUi?: { item: string; inputKind: string }[];
@ApiPropertyOptional({
description: "Requested items this party has not yet satisfied (partial resend).",
example: ["carCertificate"],
})
remainingItems?: string[];
} }
export class UserResendUploadResponseDto { export class UserResendUploadResponseDto {

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
/**
* Body for field expert to verify one or two party OTPs for expert-initiated IN_PERSON blame.
* First party must verify; second party required only when type is THIRD_PARTY.
*/
export class VerifyPartyOtpsDto {
@ApiProperty({
description: "First party phone number (must have requested OTP via /user/send-otp)",
example: "09123456789",
})
firstPartyPhoneNumber: string;
@ApiProperty({
description: "OTP received by first party",
example: "258567",
})
firstPartyOtp: string;
@ApiPropertyOptional({
description: "Second party phone number. Required when blame type is THIRD_PARTY.",
example: "09187654321",
})
secondPartyPhoneNumber?: string;
@ApiPropertyOptional({
description: "OTP received by second party. Required when secondPartyPhoneNumber is provided.",
example: "123456",
})
secondPartyOtp?: string;
}

View File

@@ -50,5 +50,10 @@ export class AccidentInfo {
@Prop({ type: AccidentClassificationSchema }) @Prop({ type: AccidentClassificationSchema })
classification?: AccidentClassification; classification?: AccidentClassification;
/** CAR_BODY: weather / road / light at time of accident */
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
} }
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo); export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);

View File

@@ -1,19 +1,27 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose"; import { HydratedDocument, Types } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { Party, PartySchema } from "./partyRole.enum"; import { Party, PartySchema } from "./partyRole.enum";
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type"; import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
import { Workflow, WorkflowSchema } from "./workflow.type"; import { Workflow, WorkflowSchema } from "./workflow.type";
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type"; import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CreationMethod, FilledBy } from "./request-management.schema";
/** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */
@Schema({ _id: false })
export class CarBodyInsuranceDetail {
@Prop() policyNumber?: string;
@Prop() startDate?: string;
@Prop() endDate?: string;
@Prop() insurerCompany?: string;
@Prop({ type: [String] }) coverages?: string[];
}
export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail);
@Schema({ _id: false }) @Schema({ _id: false })
export class BlameRequestSnapshot { export class BlameRequestSnapshot {
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] }) @Prop({ type: [PartySchema], default: [] })
parties?: Party[]; parties?: Party[];
} }
@@ -45,9 +53,6 @@ export class BlameRequest {
@Prop({ type: WorkflowSchema }) @Prop({ type: WorkflowSchema })
workflow?: Workflow; workflow?: Workflow;
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] }) @Prop({ type: [PartySchema], default: [] })
parties: Party[]; parties: Party[];
@@ -57,12 +62,40 @@ export class BlameRequest {
@Prop({ type: [HistoryEventSchema], default: [] }) @Prop({ type: [HistoryEventSchema], default: [] })
history: HistoryEvent[]; history: HistoryEvent[];
/** CAR_BODY only: first party car-body insurance (mocked inquiry) legacy; prefer parties[0].insurance.carBodyInsurance */
@Prop({ type: CarBodyInsuranceDetailSchema })
carBodyInsuranceDetail?: CarBodyInsuranceDetail;
/** /**
* Optional snapshot structure (kept for future use / read-optimized copies). * Optional snapshot structure (kept for future use / read-optimized copies).
* Source of truth remains the top-level fields of this document. * Source of truth remains the top-level fields of this document.
*/ */
@Prop({ type: BlameRequestSnapshotSchema, required: false }) @Prop({ type: BlameRequestSnapshotSchema, required: false })
snapshot?: BlameRequestSnapshot; snapshot?: BlameRequestSnapshot;
/** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */
@Prop({ default: false })
expertInitiated?: boolean;
/** Field expert who created this file (for expert-initiated flows). */
@Prop({ type: Types.ObjectId })
initiatedByFieldExpertId?: Types.ObjectId;
/** True when this blame was created by a registrar. */
@Prop({ default: false })
registrarInitiated?: boolean;
/** Registrar who created this file (for registrar-initiated flows). */
@Prop({ type: Types.ObjectId })
initiatedByRegistrarId?: Types.ObjectId;
/** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */
@Prop({ type: String, enum: CreationMethod })
creationMethod?: CreationMethod;
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
@Prop({ type: String, enum: FilledBy })
filledBy?: FilledBy;
} }
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;

View File

@@ -0,0 +1,26 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
/**
* Immutable snapshot of the expert at reply time (field expert or damage expert).
* Stored on blame `expert.decision` and claim `evaluation.damageExpertReply*`.
*/
@Schema({ _id: false })
export class ExpertProfileSnapshot {
@Prop()
firstName?: string;
@Prop()
lastName?: string;
@Prop()
email?: string;
@Prop()
insuActivityCo?: string;
@Prop()
mobile?: string;
}
export const ExpertProfileSnapshotSchema =
SchemaFactory.createForClass(ExpertProfileSnapshot);

View File

@@ -1,6 +1,10 @@
//! NEW //! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "./expert-profile-snapshot.schema";
@Schema({ _id: false }) @Schema({ _id: false })
@@ -52,6 +56,10 @@ export class ExpertDecision {
accidentReason: { id: string; label: string; fanavaran: number }; accidentReason: { id: string; label: string; fanavaran: number };
accidentType: { id: string; label: string }; accidentType: { id: string; label: string };
}; };
/** Field expert profile at decision time (from `expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision); export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision);
@@ -64,6 +72,10 @@ export class PartyResendRequest {
@Prop({ type: [String], default: [] }) @Prop({ type: [String], default: [] })
requestedItems: string[]; // Array of ResendItemType values requestedItems: string[]; // Array of ResendItemType values
/** @deprecated Resend UI uses requestedItems + inputKind from API; do not rely on workflow steps. */
@Prop({ type: [String], default: [] })
workflowStepKeys?: string[];
@Prop() @Prop()
description?: string; description?: string;
@@ -75,6 +87,20 @@ export class PartyResendRequest {
@Prop({ type: Date }) @Prop({ type: Date })
completedAt?: Date; completedAt?: Date;
/** Document resend uploads keyed by ResendItemType (e.g. drivingLicense). */
@Prop({ type: Object, default: {} })
uploadedDocuments?: Record<string, Types.ObjectId>;
@Prop({ type: Types.ObjectId })
resendVoiceId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
resendVideoId?: Types.ObjectId;
/** Fulfills requestedItems `description` (text). */
@Prop({ type: String })
userTextDescription?: string;
} }
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest); export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
@@ -89,6 +115,16 @@ export class ExpertResend {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
requestedByExpertId?: Types.ObjectId; requestedByExpertId?: Types.ObjectId;
/** Field expert profile when resend was requested (`expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
/**
* @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind.
*/
@Prop({ type: [String], default: [] })
combinedResubmitStepKeys?: string[];
} }
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend); export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);

View File

@@ -70,6 +70,16 @@ export class Insurance {
@Prop() financialCeiling?: string; @Prop() financialCeiling?: string;
@Prop({ type: [String] }) @Prop({ type: [String] })
coverages?: string[]; coverages?: string[];
/** CAR_BODY only: mocked car-body insurance inquiry result */
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyInsurance?: {
policyNumber?: string;
startDate?: string;
endDate?: string;
insurerCompany?: string;
coverages?: string[];
};
} }
export const InsuranceSchema = SchemaFactory.createForClass(Insurance); export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
@@ -87,6 +97,13 @@ export class PartyStatement {
@Prop({ type: String }) @Prop({ type: String })
description?: string; description?: string;
/** CAR_BODY: accident conditions from description step */
@Prop() accidentDate?: Date;
@Prop() accidentTime?: string;
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
} }
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement); export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
@@ -130,6 +147,13 @@ export class Party {
@Prop({ type: PersonSchema }) @Prop({ type: PersonSchema })
person: Person; person: Person;
/**
* CAR_BODY only: first form accident with car vs object.
* Second form (guilty/damaged) may be added later as carBodySecondForm.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyFirstForm?: { car?: boolean; object?: boolean };
/** /**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION). * Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/ */

View File

@@ -249,6 +249,10 @@ export class SubmitReply {
@Prop() @Prop()
systemResponse: string; systemResponse: string;
/** Field expert profile at reply time (`expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export class ExpertResendParties { export class ExpertResendParties {
@@ -271,6 +275,10 @@ export class ActorLockDetails {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
actorId?: Types.ObjectId; actorId?: Types.ObjectId;
/** Field expert profile when lock was taken (`expert` collection). */
@Prop({ type: Object })
expertProfileSnapshot?: Record<string, string | undefined>;
} }
export enum CreationMethod { export enum CreationMethod {
@@ -282,6 +290,7 @@ export enum CreationMethod {
export enum FilledBy { export enum FilledBy {
CUSTOMER = "customer", CUSTOMER = "customer",
EXPERT = "expert", EXPERT = "expert",
REGISTRAR = "registrar",
} }
export class ExpertLinkInfo { export class ExpertLinkInfo {
@@ -449,6 +458,14 @@ export class RequestManagementModel {
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
isHandledStatsUpdated?: boolean; isHandledStatsUpdated?: boolean;
/** Latest field expert profile when expert requested party resend (V1 `sendAgainRequest`). */
@Prop({ type: Object })
expertResendByExpertProfileSnapshot?: Record<string, string | undefined>;
/** Field expert profile when expert marked in-person visit (V1 `inPersonVisit`). */
@Prop({ type: Object })
fieldExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
// Expert-initiated file tracking // Expert-initiated file tracking
@Prop({ type: Boolean, default: false }) @Prop({ type: Boolean, default: false })
expertInitiated?: boolean; expertInitiated?: boolean;

View File

@@ -2,6 +2,10 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
} from "./expert-profile-snapshot.schema";
@Schema({ _id: false }) @Schema({ _id: false })
export class ActorLock { export class ActorLock {
@@ -13,6 +17,10 @@ export class ActorLock {
@Prop({ type: String, default: "expert" }) @Prop({ type: String, default: "expert" })
actorRole: "expert" | "admin"; actorRole: "expert" | "admin";
/** Field expert profile when lock was taken (`expert` collection). */
@Prop({ type: ExpertProfileSnapshotSchema })
expertProfileSnapshot?: ExpertProfileSnapshot;
} }
export const ActorLockSchema = SchemaFactory.createForClass(ActorLock); export const ActorLockSchema = SchemaFactory.createForClass(ActorLock);

View File

@@ -0,0 +1,549 @@
import { extname } from "node:path";
import {
Controller,
Post,
Body,
Param,
UseGuards,
Get,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import {
ApiBearerAuth,
ApiBody,
ApiParam,
ApiTags,
ApiOperation,
ApiResponse,
ApiConsumes,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { ExpertCompleteThirdPartyFormV2Dto } from "./dto/expert-complete-third-party-form.v2.dto";
import { ExpertCompleteCarBodyFormV2Dto } from "./dto/expert-complete-car-body-form.v2.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto";
import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
/**
* V2 expert-initiated blame API: uses BlameRequest (workflow) model.
* Field experts create files via LINK (send link to user) or IN_PERSON (expert fills on-site).
* Only the initiating field expert can see/review these files.
*/
@ApiTags("expert-initiated-blame (v2)")
@Controller("v2/expert-initiated-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FIELD_EXPERT)
export class ExpertInitiatedV2Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("my-files")
@ApiOperation({
summary: "[V2] List my expert-initiated blame files",
description:
"Returns all BlameRequest (workflow) files created by the current field expert.",
})
@ApiResponse({ status: 200, description: "List of expert-initiated files" })
async getMyFilesV2(@CurrentUser() expert: any) {
return this.requestManagementService.getMyExpertInitiatedFilesV2(expert);
}
@Get("blame/:requestId")
@ApiOperation({
summary: "[V2] Get one expert-initiated blame request",
description: "Returns a single blame request. Only the initiating field expert can access.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiResponse({ status: 200, description: "Blame request" })
async getBlameRequestV2(
@Param("requestId") requestId: string,
@CurrentUser() expert: any,
) {
return this.requestManagementService.getBlameRequestV2(requestId, expert);
}
@Post("create")
@ApiOperation({
summary: "[V2] Create expert-initiated blame file",
description:
"Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.",
})
@ApiBody({
type: CreateExpertInitiatedFileDto,
examples: {
thirdPartyInPerson: {
summary: "Third-party, IN_PERSON (both parties)",
description:
"Expert fills form on-site for both parties.",
value: {
type: "THIRD_PARTY",
creationMethod: "IN_PERSON",
firstPartyPhoneNumber: "09123456789",
secondPartyPhoneNumber: "09187654321",
},
},
thirdPartyLink: {
summary: "Third-party, LINK",
description:
"Expert sends link to first and second party by phone.",
value: {
type: "THIRD_PARTY",
creationMethod: "LINK",
firstPartyPhoneNumber: "09123456789",
},
},
carBodyInPerson: {
summary: "Car-body, IN_PERSON",
description: "Expert fills form on-site for single party (car body).",
value: {
type: "CAR_BODY",
creationMethod: "IN_PERSON",
firstPartyPhoneNumber: "09123456789",
},
},
carBodyLink: {
summary: "Car-body, LINK",
description:
"Expert sends link to party by phone. Only first party phone needed.",
value: {
type: "CAR_BODY",
creationMethod: "LINK",
firstPartyPhoneNumber: "09123456789",
},
},
},
})
@ApiResponse({
status: 201,
description: "File created",
schema: {
type: "object",
properties: {
requestId: { type: "string" },
publicId: { type: "string" },
linkUrl: { type: "string", description: "Present for LINK method" },
},
},
})
async createV2(
@CurrentUser() expert: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
expert,
dto,
);
}
@Post("send-link/:requestId")
@ApiOperation({
summary: "[V2] Send blame link to party/parties (LINK)",
description:
"For expert-initiated LINK files only. Sends SMS with template `yara-field-expert-link` to first party (and second party for THIRD_PARTY). Tokens: token=#1(بدنه/ثالث), token2=#2(expert name), token3=#3(invite link built exactly like add-second-party flow: `${URL}/{frontendRoute}?token={requestId}`).",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: SendExpertInitiatedLinkV2Dto })
@ApiResponse({
status: 200,
description: "Link sent; recipients can open and fill via normal user flow",
schema: {
type: "object",
properties: {
sent: { type: "boolean" },
linkUrl: { type: "string" },
sentTo: {
type: "array",
items: {
type: "object",
properties: {
role: { type: "string", enum: ["FIRST", "SECOND"] },
phoneNumber: { type: "string" },
smsSent: { type: "boolean" },
},
},
},
},
},
})
async sendLinkV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: SendExpertInitiatedLinkV2Dto,
) {
return this.requestManagementService.sendLinkV2(expert, requestId, dto);
}
@Post("send-party-otps/:requestId")
@ApiOperation({
summary: "[V2] Send OTP to party/parties (IN_PERSON)",
description:
"Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: SendPartyOtpsDto })
@ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" })
async sendPartyOtpsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpsDto,
) {
return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
}
@Post("verify-party-otps/:requestId")
@ApiOperation({
summary: "[V2] Verify party OTPs (IN_PERSON)",
description:
"After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: VerifyPartyOtpsDto })
@ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" })
async verifyPartyOtpsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpsDto,
) {
return this.requestManagementService.verifyPartyOtpsV2(expert, requestId, dto);
}
@Post("complete-blame-data/:requestId")
@ApiOperation({
summary: "[V2] Submit all blame data (IN_PERSON)",
description:
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.",
examples: {
THIRD_PARTY: {
summary: "THIRD_PARTY",
description: "Use when the blame file type is THIRD_PARTY",
value: {
firstPartyPhoneNumber: "09123456789",
firstPartyInitialForm: {
expertOpinion: false,
imDamaged: false,
imGuilty: true,
},
firstPartyPlate: {
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 13770624,
driverBirthday: "1370-01-01",
},
expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" },
secondParty: {
phoneNumber: "09187654321",
initialForm: {
expertOpinion: false,
imDamaged: true,
imGuilty: false,
},
plate: {
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 13700720,
driverBirthday: "1370-01-01",
},
},
guiltyPartyPhoneNumber: "09123456789",
},
},
CAR_BODY: {
summary: "CAR_BODY",
description: "Use when the blame file type is CAR_BODY",
value: {
firstPartyPhoneNumber: "09123456789",
firstPartyInitialForm: {
expertOpinion: false,
imDamaged: false,
imGuilty: true,
},
carBodyForm: { car: true, object: false },
firstPartyPlate: {
plateId: "",
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 1370,
driverBirthday: "1370-01-01",
},
expertDescription: {
desc: "توضیح حادثه",
accidentDate: "2025-01-15",
accidentTime: "14:30",
weatherCondition: "صاف",
roadCondition: "خشک",
lightCondition: "روز",
},
},
},
},
schema: { type: "object" },
})
@ApiResponse({ status: 200, description: "Blame form completed; next: add-locations" })
async completeBlameDataV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() formData: ExpertCompleteThirdPartyFormV2Dto | ExpertCompleteCarBodyFormV2Dto,
) {
return this.requestManagementService.expertCompleteBlameDataV2(
expert,
requestId,
formData,
);
}
@Post("add-locations/:requestId")
@ApiOperation({
summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame",
description:
"Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
type: ExpertCompleteLocationV2Dto,
examples: {
scene: {
summary: "One scene location (all IN_PERSON types)",
value: {
location: { lat: 35.6892, lon: 51.389 },
},
},
},
})
@ApiResponse({ status: 200, description: "Locations saved; next: upload party signature(s)" })
async addLocationsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: ExpertCompleteLocationV2Dto,
) {
return this.requestManagementService.expertAddLocationsForBlameV2(
expert,
requestId,
dto as any,
);
}
@Post("upload-video/:requestId")
@ApiOperation({
summary: "[V2] Expert uploads video for expert-initiated BlameRequest",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
properties: { file: { type: "string", format: "binary" } },
},
})
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 20 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `expert-${file.originalname}-${unique}${ex}`);
},
}),
}),
)
@ApiResponse({ status: 200, description: "Video uploaded" })
async uploadVideoV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@UploadedFile() file?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVideoForBlameV2(
expert,
requestId,
file,
);
}
@Post("upload-voice/:requestId")
@ApiOperation({
summary: "[V2] Expert uploads voice for expert-initiated BlameRequest",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
properties: { voice: { type: "string", format: "binary" } },
},
})
@UseInterceptors(
FileInterceptor("voice", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const flname = file.originalname.split(".")[0];
callback(null, `expert-${flname}-${unique}${ex}`);
},
}),
}),
)
@ApiResponse({ status: 200, description: "Voice uploaded" })
async uploadVoiceV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@UploadedFile() voice?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVoiceForBlameV2(
expert,
requestId,
voice,
);
}
@Post("add-accident-fields/:requestId")
@ApiOperation({
summary: "[V2] Expert adds accident fields to expert-initiated BlameRequest",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@ApiResponse({ status: 200, description: "Accident fields added" })
async addAccidentFieldsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
expert,
requestId,
fields,
);
}
@Post("upload-party-signature/:requestId")
@ApiOperation({
summary: "[V2] Expert uploads party signature (IN_PERSON)",
description:
"For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
required: ["partyRole", "sign"],
properties: {
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
isAccept: { type: "boolean", default: true },
sign: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `expert-party-${unique}${ex}`);
},
}),
}),
)
@ApiResponse({ status: 200, description: "Signature recorded" })
async uploadPartySignatureV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@UploadedFile() sign?: Express.Multer.File,
) {
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
? body.partyRole
: ("FIRST" as const);
const isAccept = body.isAccept === false || body.isAccept === "false" ? false : true;
return this.requestManagementService.expertUploadPartySignatureV2(
expert,
requestId,
partyRole as PartyRole,
isAccept,
sign!,
);
}
@Post("create-claim-from-blame/:blameRequestId")
@ApiOperation({
summary: "[V2] Create claim from expert-initiated IN_PERSON blame",
description:
"Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.",
})
@ApiParam({ name: "blameRequestId", description: "Completed blame request ID" })
@ApiResponse({ status: 201, description: "Claim created" })
async createClaimFromBlame(
@CurrentUser() expert: any,
@Param("blameRequestId") blameRequestId: string,
) {
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
blameRequestId,
expert,
);
}
@Post("complete-claim-data/:claimRequestId")
@ApiOperation({
summary: "Submit claim-needed data (expert-initiated IN_PERSON)",
})
@ApiParam({ name: "claimRequestId", description: "Claim file ID" })
@ApiBody({ type: ExpertCompleteClaimDataDto })
@ApiResponse({ status: 200, description: "Claim data updated" })
async completeClaimData(
@CurrentUser() expert: any,
@Param("claimRequestId") claimRequestId: string,
@Body() dto: ExpertCompleteClaimDataDto,
) {
return this.claimRequestManagementService.expertCompleteClaimData(
claimRequestId,
expert,
dto,
);
}
}

View File

@@ -0,0 +1,155 @@
import { extname } from "node:path";
import { Body, Controller, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { PartyRole } from "./entities/schema/partyRole.enum";
@ApiTags("registrar-initiated-blame (v1)")
@Controller("registrar-initiated-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.REGISTRAR)
export class RegistrarInitiatedController {
constructor(private readonly requestManagementService: RequestManagementService) {}
@Post("create")
@ApiOperation({ summary: "Registrar creates IN_PERSON blame file" })
@ApiBody({ type: CreateRegistrarInitiatedFileDto })
create(@CurrentUser() registrar: any, @Body() dto: CreateRegistrarInitiatedFileDto) {
return this.requestManagementService.createRegistrarInitiatedBlame(registrar, dto);
}
@Get("my-files")
myFiles(@CurrentUser() registrar: any) {
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
}
@Get("blame/:requestId")
getBlame(@Param("requestId") requestId: string, @CurrentUser() registrar: any) {
return this.requestManagementService.getBlameRequestV2(requestId, registrar);
}
@Post("send-party-otps/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpsDto })
sendPartyOtps(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpsDto,
) {
return this.requestManagementService.sendPartyOtpsV2(registrar, requestId, dto);
}
@Post("verify-party-otps/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpsDto })
verifyPartyOtps(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpsDto,
) {
return this.requestManagementService.verifyPartyOtpsV2(registrar, requestId, dto);
}
@Post("complete-blame-data/:requestId")
completeBlameData(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() body: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(registrar, requestId, body);
}
@Post("upload-video/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 20 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadVideo(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@UploadedFile() file?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVideoForBlameV2(registrar, requestId, file);
}
@Post("upload-voice/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("voice", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadVoice(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@UploadedFile() voice?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVoiceForBlameV2(registrar, requestId, voice);
}
@Post("add-accident-fields/:requestId")
@ApiBody({ type: ExpertAccidentFieldsDto })
addAccidentFields(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
registrar,
requestId,
fields,
);
}
@Post("upload-party-signature/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadPartySignature(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: boolean | string },
@UploadedFile() sign?: Express.Multer.File,
) {
const role =
body.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body.isAccept === false || body.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV2(
registrar,
requestId,
role,
isAccept,
sign!,
);
}
}

View File

@@ -10,9 +10,11 @@ import { BlameRequestDbService } from "src/request-management/entities/db-servic
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module"; import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { HashModule } from "src/utils/hash/hash.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
import { AuthModule } from "src/auth/auth.module";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
import { UserSignDbService } from "./entities/db-service/sign.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service";
@@ -35,14 +37,17 @@ import { RequestManagementService } from "./request-management.service";
import { RequestManagementController } from "./request-management.controller"; import { RequestManagementController } from "./request-management.controller";
import { RequestManagementV2Controller } from "./request-management.v2.controller"; import { RequestManagementV2Controller } from "./request-management.v2.controller";
import { ExpertInitiatedController } from "./expert-initiated.controller"; import { ExpertInitiatedController } from "./expert-initiated.controller";
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
ClientModule, ClientModule,
SandHubModule, SandHubModule,
SmsManagerModule, SmsOrchestrationModule,
PublicIdModule, PublicIdModule,
HashModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
PlatesModule, PlatesModule,
MulterModule.register({ MulterModule.register({
@@ -58,11 +63,14 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
]), ]),
forwardRef(() => ClaimRequestManagementModule), forwardRef(() => ClaimRequestManagementModule),
CronModule, CronModule,
AuthModule,
], ],
controllers: [ controllers: [
RequestManagementController, RequestManagementController,
RequestManagementV2Controller, RequestManagementV2Controller,
ExpertInitiatedController, ExpertInitiatedController,
ExpertInitiatedV2Controller,
RegistrarInitiatedController,
], ],
providers: [ providers: [
RequestManagementService, RequestManagementService,

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiConsumes, ApiConsumes,
ApiOperation,
ApiParam, ApiParam,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
@@ -33,6 +34,7 @@ import {
CreateBlameRequestDtoV2, CreateBlameRequestDtoV2,
DescriptionDto, DescriptionDto,
LocationDto, LocationDto,
CarBodyFormDto,
} from "./dto/create-request-management.dto"; } from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
@@ -64,6 +66,32 @@ export class RequestManagementV2Controller {
); );
} }
@Get()
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getAllBlameRequestsV2(
@CurrentUser() user: any,
) {
return this.requestManagementService.getAllBlameRequestsV2(user);
}
/**
* Get one blame request by id. Allowed for the request owner (party) or the initiating field expert.
*/
@Get(":requestId")
@ApiOperation({
summary: "Get one blame request (v2)",
description:
"Returns a minimal, user-safe payload: requestNo, publicId, type, status, blameStatus, workflow, parties (PII stripped), expert (with **expertName**), carBodyInsuranceDetail, plus **claimCreation** ({ hasClaim, shouldGuideToCreateClaim }). History and other internal fields are omitted.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getBlameRequestV2(
@Param("requestId") requestId: string,
@CurrentUser() user: any,
) {
return this.requestManagementService.getBlameRequestV2(requestId, user);
}
@Post("/blame-confession/:requestId") @Post("/blame-confession/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: BlameConfessionDtoV2 }) @ApiBody({ type: BlameConfessionDtoV2 })
@@ -76,6 +104,19 @@ export class RequestManagementV2Controller {
return this.requestManagementService.blameConfessionV2(requestId, body, user); return this.requestManagementService.blameConfessionV2(requestId, body, user);
} }
/** CAR_BODY only: submit accident type (car vs object). Call this when nextStep is CAR_BODY_ACCIDENT_TYPE. */
@Post("/car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@UseGuards(GlobalGuard)
async carBodyFormV2(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() user,
) {
return this.requestManagementService.carBodyAccidentTypeFormV2(requestId, body, user);
}
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",
@@ -185,7 +226,28 @@ export class RequestManagementV2Controller {
@Post("/add-detail-description/:requestId") @Post("/add-detail-description/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto }) @ApiBody({
description:
"THIRD_PARTY: send only desc. CAR_BODY: send desc + accidentDate, accidentTime, weatherCondition, roadCondition, lightCondition (all required).",
type: DescriptionDto,
examples: {
thirdParty: {
summary: "THIRD_PARTY (only desc)",
value: { desc: "Accident description text" },
},
carBody: {
summary: "CAR_BODY (desc + conditions)",
value: {
desc: "Accident description",
accidentDate: "2025-12-08",
accidentTime: "14:30",
weatherCondition: "صاف",
roadCondition: "خشک",
lightCondition: "روز",
},
},
},
})
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
async addDescriptionV2( async addDescriptionV2(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@@ -331,6 +393,11 @@ export class RequestManagementV2Controller {
format: "binary", format: "binary",
description: "Video file (if requested)", description: "Video file (if requested)",
}, },
description: {
type: "string",
description:
"Text description when expert requested ResendItemType.description",
},
}, },
}, },
}) })
@@ -371,11 +438,13 @@ export class RequestManagementV2Controller {
voice?: Express.Multer.File[]; voice?: Express.Multer.File[];
video?: Express.Multer.File[]; video?: Express.Multer.File[];
}, },
@Body("description") description?: string,
) { ) {
return this.requestManagementService.uploadResendDocumentsV2( return this.requestManagementService.uploadResendDocumentsV2(
requestId, requestId,
user.sub, user.sub,
files, files,
description,
); );
} }
} }

View File

@@ -29,7 +29,144 @@ export class SandHubService {
private readonly sandHubDbService: SandHubDbService, private readonly sandHubDbService: SandHubDbService,
) {} ) {}
/**
* When false (default), no outbound HTTP to SandHub/Tejarat gateways — all inquiries return mocks.
* Set `SANDHUB_USE_REAL_API=true` to restore live calls.
*/
private useLiveSandHubApis(): boolean {
return process.env.SANDHUB_USE_REAL_API === "true";
}
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
return {
PrntPlcyCmpDocNo: "1403/1143-70591/200/35",
MapTypNam: "پرايد هاچ بک -111",
MtrNum: "5215907",
ShsNum: "NAS431100E5798656",
DisFnYrNum: "0",
DisLfYrNum: "0",
DisPrsnYrNum: "0",
DisPrsnYrPrcnt: "0",
DisFnYrPrcnt: "0",
DisLfYrPrcnt: "0",
vin: "IRPC941V2BD798656",
MapVehicleSystemName: "ثبت نشده",
LfCvrCptl: 0,
FnCvrCptl: 0,
PrsnCvrCptl: 0,
VehicleSystemCode: 1,
EdrsJson: '[{"id":1,"Dsc":" الحاقيه توضيحات ندارد"}]',
PersonCvrCptl: 12000000000,
LifeCvrCptl: 16000000000,
FinancialCvrCptl: 4000000000,
CarGroupCode: 3,
CylCnt: 4,
LastCompanyDocumentNumber: "03/1031/2835/1001/662",
UsageCode: "8",
MapUsageCode: 1,
MapUsageName: "شخصي",
Plk1: 59,
Plk2: 16,
Plk3: 419,
PlkSrl: 78,
PrintEndorsCompanyDocumentNumber: "بيمه نامه الحاقيه ندارد.",
EndorseDate: null,
InsuranceFullName: null,
SystemField: "سايپا",
TypeField: "111SE",
UsageField: "سواري",
MainColorField: "سفيد",
SecondColorField: "سفيد شيري",
ModelField: "1394",
CapacityField: "جمعا 4 نفر",
CylinderNumberField: "4",
EngineNumberField: "5215907",
ChassisNumberField: "NAS431100E5798656",
VinNumberField: "IRPC941V2BD798656",
InstallDateField: "1403/03/01",
AxelNumberField: "2",
WheelNumberField: "4",
CompanyName: "بیمه سامان",
CompanyCode: "15",
IssueDate: "1403/04/06",
SatrtDate: "1403/04/06",
EndDate: "1404/04/06",
Thrname: "",
EndorseText: null,
PolicyHealthLossCount: 0,
PolicyFinancialLossCount: 0,
PolicyPersonLossCount: 0,
Tonage: 0,
ThirdPolicyCode: 10502330579,
DiscountPersonPercent: null,
DiscountThirdPercent: null,
SystemCodeCii: 0,
SystemNameCii: "ثبت نشده",
TypeCodeCii: 12189,
TypeNameCii: "پرايد هاچ بک -111",
UsageNameCii: "سواري",
UsageCodeCii: 1,
ModelCii: 1394,
damageTypes: [],
StatusTypeCode: 1,
};
}
/**
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
*/
private buildMockSandHubResponseForUrl(url: string): any {
const u = (url || "").toLowerCase();
if (u.includes("personal-inquiry")) {
return {
message: "success",
data: {
firstName: "نام",
lastName: "خانوادگی",
fatherName: "-",
birthCertificateNumber: "-",
nin: "-",
},
};
}
if (u.includes("driver-license-check")) {
return {
data: {
IsSucceed: true,
Message: "mock",
},
};
}
if (u.includes("ownership")) {
return {
data: {
IsSuccess: true,
},
};
}
if (u.includes("sheba")) {
return {
ReturnValue: true,
HasError: false,
Message: "mock-sheba-ok",
};
}
if (u.includes("block-inquiry") || u.includes("tejarat")) {
return this.mapNewApiResponseToOldFormat(
this.getDefaultMockPlateInquiryRaw(),
);
}
this.logger.warn(
`[MOCK] Unrecognized SandHub URL; returning generic OK: ${url}`,
);
return { data: {}, message: "mock-ok" };
}
private async getAccessToken(): Promise<string> { private async getAccessToken(): Promise<string> {
if (!this.useLiveSandHubApis()) {
return "mock-sandhub-access-token";
}
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) { if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
return this.loginToken; return this.loginToken;
} }
@@ -67,6 +204,9 @@ export class SandHubService {
} }
private async getTejaratAccessToken(): Promise<string> { private async getTejaratAccessToken(): Promise<string> {
if (!this.useLiveSandHubApis()) {
return "mock-tejarat-access-token";
}
if ( if (
this.tejaratAccessToken && this.tejaratAccessToken &&
this.tejaratTokenExpiry && this.tejaratTokenExpiry &&
@@ -122,6 +262,10 @@ export class SandHubService {
} }
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) { private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
if (!this.useLiveSandHubApis()) {
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
return this.getDefaultMockPlateInquiryRaw();
}
const INITIAL_DELAY = 500; const INITIAL_DELAY = 500;
const BACKOFF_FACTOR = 2; const BACKOFF_FACTOR = 2;
@@ -183,7 +327,14 @@ export class SandHubService {
}; };
const requestUrl = `${baseUrl}/block-inquiry-tejarat`; const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
const raw = await this.makeTejaratRequest(requestUrl, requestPayload); const raw = this.useLiveSandHubApis()
? await this.makeTejaratRequest(requestUrl, requestPayload)
: this.getDefaultMockPlateInquiryRaw();
if (!this.useLiveSandHubApis()) {
this.logger.debug(
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapNewApiResponseToOldFormat(raw); const mapped = this.mapNewApiResponseToOldFormat(raw);
return { raw, mapped }; return { raw, mapped };
} }
@@ -193,6 +344,10 @@ export class SandHubService {
} }
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) { private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
if (!this.useLiveSandHubApis()) {
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
return this.buildMockSandHubResponseForUrl(url);
}
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const INITIAL_DELAY = 1000; const INITIAL_DELAY = 1000;
const BACKOFF_FACTOR = 2; const BACKOFF_FACTOR = 2;
@@ -293,16 +448,25 @@ export class SandHubService {
rightTwoDigits: String(userDetail.plate.ir), rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer, nationalCode: userDetail.nationalCodeOfInsurer,
}; };
const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`; const base = process.env.SANDHUB_BASE_URL ?? "";
const response = await this.makeSandHubRequest(requestUrl, requestPayload); const requestUrl = `${base}/block-inquiry-tejarat`;
// Map the new API response format to the old format let response: any;
let result = this.mapNewApiResponseToOldFormat(response); if (this.useLiveSandHubApis()) {
response = await this.makeSandHubRequest(requestUrl, requestPayload);
if (result.usgCod !== "8") { } else {
throw new Error("خودرو شما شخصی / سواری نمی باشد") this.logger.debug(
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
);
response = this.getDefaultMockPlateInquiryRaw();
} }
const result = this.mapNewApiResponseToOldFormat(response);
// if (result.usgCod !== "8") {
// throw new Error("خودرو شما شخصی / سواری نمی باشد")
// }
return result; return result;
} catch (err) { } catch (err) {
throw new Error(err); throw new Error(err);

View File

@@ -0,0 +1,24 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose";
import { SmsText, SmsTextDocument } from "../schema/sms-text.schema";
@Injectable()
export class SmsTextDbService {
constructor(
@InjectModel(SmsText.name)
private readonly smsTextModel: Model<SmsTextDocument>,
) {}
async findOne(filter: FilterQuery<SmsText>): Promise<SmsTextDocument | null> {
return this.smsTextModel.findOne(filter);
}
async upsert(key: string, text: string): Promise<void> {
await this.smsTextModel.updateOne(
{ key },
{ $setOnInsert: { key, text, active: true } },
{ upsert: true },
);
}
}

View File

@@ -0,0 +1,17 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
@Schema({ collection: "sms-texts", timestamps: true, versionKey: false })
export class SmsText {
@Prop({ required: true, unique: true, index: true })
key: string;
@Prop({ required: true })
text: string;
@Prop({ default: true })
active: boolean;
}
export type SmsTextDocument = HydratedDocument<SmsText>;
export const SmsTextSchema = SchemaFactory.createForClass(SmsText);

View File

@@ -0,0 +1,78 @@
const MAX_RAW_LEN = 8000;
export interface KavenegarNormalized {
httpLikeStatus?: number;
message?: string;
raw: string;
}
export function safeJsonStringify(value: unknown): string {
try {
const s = JSON.stringify(value);
return s.length > MAX_RAW_LEN
? `${s.slice(0, MAX_RAW_LEN)}...[truncated]`
: s;
} catch {
return String(value);
}
}
/**
* Kavenegar REST responses use `{ return: { status, message }, ... }`.
* Transport failures may yield non-JSON strings or malformed bodies.
*/
export function normalizeKavenegarBody(body: unknown): KavenegarNormalized {
if (typeof body === "string") {
try {
return normalizeKavenegarBody(JSON.parse(body) as unknown);
} catch {
const raw =
body.length > MAX_RAW_LEN
? `${body.slice(0, MAX_RAW_LEN)}...[truncated]`
: body;
return { raw };
}
}
let httpLikeStatus: number | undefined;
let message: string | undefined;
if (body && typeof body === "object" && !Array.isArray(body)) {
const ret = (body as { return?: { status?: unknown; message?: unknown } })
.return;
if (ret && typeof ret === "object") {
if (typeof ret.status === "number" && Number.isFinite(ret.status)) {
httpLikeStatus = ret.status;
}
if (typeof ret.message === "string") {
message = ret.message;
}
}
}
return {
httpLikeStatus,
message,
raw: safeJsonStringify(body),
};
}
/** Kavenegar uses `return.status === 200` for a successful API call. */
export function isKavenegarSuccess(body: unknown): boolean {
return normalizeKavenegarBody(body).httpLikeStatus === 200;
}
/** The nest client sometimes rejects with a JSON string `{ "error": "..." }`. */
export function unwrapKavenegarTransportError(err: unknown): unknown {
if (typeof err === "string") {
try {
return JSON.parse(err) as unknown;
} catch {
return { transportMessage: err };
}
}
if (err instanceof Error) {
return { name: err.name, message: err.message };
}
return err;
}

View File

@@ -0,0 +1,18 @@
import { KavenegarModule } from "@fraybabak/kavenegar_nest";
import { Module } from "@nestjs/common";
import * as dotenv from "dotenv";
import { SmsGatewayService } from "./sms-gateway.service";
dotenv.config();
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@Module({
imports: [
KavenegarModule.forRoot({
apikey: process.env.SMS_API_KEY || "",
}),
],
providers: [SmsGatewayService],
exports: [SmsGatewayService],
})
export class SmsGatewayModule {}

View File

@@ -0,0 +1,110 @@
import { KavenegarService } from "@fraybabak/kavenegar_nest";
import { Injectable, Logger } from "@nestjs/common";
import {
isKavenegarSuccess,
KavenegarNormalized,
normalizeKavenegarBody,
safeJsonStringify,
unwrapKavenegarTransportError,
} from "./kavenegar-response";
import {
SmsProviderException,
SmsTransportException,
} from "./sms-provider.exception";
export interface SendMessage {
message: string;
receptor: string;
}
export interface VerifyLookUpMessage {
template: string;
token: string;
token2?: string;
token3?: string;
receptor: string;
}
@Injectable()
export class SmsGatewayService {
private readonly logger = new Logger(SmsGatewayService.name);
constructor(private readonly sender: KavenegarService) {}
async sendMessage(data: SendMessage) {
try {
const body = await this.sender.Send({
sender: "10008663",
...data,
});
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection("send", data.receptor, undefined, normalized);
throw new SmsProviderException("send", {
receptor: data.receptor,
providerBody: body,
normalized,
});
}
this.logger.log(
`Kavenegar Send ok receptor=${data.receptor} status=200`,
);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
this.logger.error(
`Kavenegar Send transport error receptor=${data.receptor} detail=${detail}`,
);
throw new SmsTransportException("send", { receptor: data.receptor }, e);
}
}
async verifyLookUp(data: VerifyLookUpMessage) {
try {
const body = await this.sender.verifyLookup(data);
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection(
"verifyLookUp",
data.receptor,
data.template,
normalized,
);
throw new SmsProviderException("verifyLookup", {
receptor: data.receptor,
template: data.template,
providerBody: body,
normalized,
});
}
this.logger.log(
`Kavenegar verifyLookUp ok receptor=${data.receptor} template=${data.template} status=200`,
);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
this.logger.error(
`Kavenegar verifyLookUp transport error receptor=${data.receptor} template=${data.template} detail=${detail}`,
);
throw new SmsTransportException(
"verifyLookup",
{ receptor: data.receptor, template: data.template },
e,
);
}
}
private logProviderRejection(
op: "send" | "verifyLookUp",
receptor: string,
template: string | undefined,
normalized: KavenegarNormalized,
) {
const t = template != null && template !== "" ? ` template=${template}` : "";
this.logger.warn(
`Kavenegar ${op} rejected receptor=${receptor}${t} providerStatus=${normalized.httpLikeStatus ?? "unknown"} providerMessage=${normalized.message ?? "n/a"} body=${normalized.raw}`,
);
}
}

View File

@@ -0,0 +1,69 @@
import { HttpException, HttpStatus } from "@nestjs/common";
import {
KavenegarNormalized,
safeJsonStringify,
unwrapKavenegarTransportError,
} from "./kavenegar-response";
export type SmsOperation = "verifyLookup" | "send";
export class SmsProviderException extends HttpException {
constructor(
public readonly operation: SmsOperation,
public readonly meta: {
receptor: string;
template?: string;
providerBody: unknown;
normalized: KavenegarNormalized;
},
) {
super(
"SMS delivery could not be confirmed by the provider.",
HttpStatus.BAD_GATEWAY,
);
}
}
export class SmsTransportException extends HttpException {
constructor(
public readonly operation: SmsOperation,
public readonly meta: { receptor: string; template?: string },
public readonly causeUnknown: unknown,
) {
super(
"SMS service failed before a provider response was received.",
HttpStatus.BAD_GATEWAY,
);
}
}
/** Single-line detail for logs (provider vs transport vs unexpected). */
export function describeSmsError(err: unknown): string {
if (err instanceof SmsProviderException) {
const n = err.meta.normalized;
return [
`SmsProviderException(${err.operation})`,
`receptor=${err.meta.receptor}`,
err.meta.template ? `template=${err.meta.template}` : "",
`providerStatus=${n.httpLikeStatus ?? "unknown"}`,
`providerMessage=${n.message ?? "n/a"}`,
`body=${n.raw}`,
]
.filter(Boolean)
.join(" ");
}
if (err instanceof SmsTransportException) {
return [
`SmsTransportException(${err.operation})`,
`receptor=${err.meta.receptor}`,
err.meta.template ? `template=${err.meta.template}` : "",
`detail=${safeJsonStringify(unwrapKavenegarTransportError(err.causeUnknown))}`,
]
.filter(Boolean)
.join(" ");
}
if (err instanceof Error) {
return `${err.name}: ${err.message}`;
}
return safeJsonStringify(err);
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
import { SmsGatewayModule } from "./provider/sms-gateway.module";
import { SmsOrchestrationService } from "./sms-orchestration.service";
@Module({
imports: [
SmsGatewayModule,
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
],
providers: [SmsTextDbService, SmsOrchestrationService],
exports: [SmsOrchestrationService],
})
export class SmsOrchestrationModule {}

View File

@@ -0,0 +1,141 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
import { SmsGatewayService } from "./provider/sms-gateway.service";
import { describeSmsError } from "./provider/sms-provider.exception";
type TemplateArgs = {
template: string;
receptor: string;
token: string;
token2?: string;
token3?: string;
};
@Injectable()
export class SmsOrchestrationService implements OnModuleInit {
private readonly logger = new Logger(SmsOrchestrationService.name);
constructor(
private readonly smsGatewayService: SmsGatewayService,
private readonly smsTextDbService: SmsTextDbService,
) {}
async onModuleInit(): Promise<void> {
await this.smsTextDbService.upsert(
"parties_disagree_notify",
"متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.",
);
await this.smsTextDbService.upsert(
"one_party_signed_wait_signature",
"طرف مقابل توافق را امضا کرده است. لطفا جهت نهایی کردن پرونده، امضای خود را ثبت نمایید.",
);
await this.smsTextDbService.upsert(
"one_party_accepted_wait_24h",
"طرف مقابل نظر کارشناس را قبول و امضا کرده است. در طی 24 ساعت آینده فرصت دارید تا پرونده را تکمیل کرده یا پرونده بسته خواهد شد.",
);
}
buildInviteLink(frontendRoute: string, requestId: string): string {
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
}
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
return this.sendTemplate({
template: "yara724-invite-link",
receptor: phoneNumber,
token: link,
});
}
async sendAuthOtp(
mobile: string,
otp: string,
template?: string,
): Promise<boolean> {
return this.sendTemplate({
template: template || process.env.AUTH_SMS_TEMPLATE || "",
receptor: mobile,
token: otp,
});
}
async sendFieldExpertLink(params: {
receptor: string;
type: BlameRequestType;
expertLastName: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-field-expert-link",
receptor: params.receptor,
token: params.type === BlameRequestType.CAR_BODY ? "بدنه" : "ثالث",
token2: params.expertLastName || "کارشناس",
token3: params.link,
});
}
async sendTextByKey(
receptor: string,
key: string,
fallbackText: string,
): Promise<boolean> {
const row = await this.smsTextDbService.findOne({ key, active: true });
const message = row?.text?.trim() || fallbackText;
try {
await this.smsGatewayService.sendMessage({ receptor, message });
this.logger.log(`[SMS] text sent key=${key} receptor=${receptor}`);
return true;
} catch (err) {
this.logger.error(
`[SMS] text send failed key=${key} receptor=${receptor}: ${describeSmsError(err)}`,
);
return false;
}
}
async sendThirdPartyAgreementSignNotice(params: {
receptor: string;
publicId: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-blame-agreement",
receptor: params.receptor,
token: params.publicId,
token2: params.link,
});
}
async sendThirdPartyExpertStartedReviewNotice(
params: {
receptor: string;
publicId: string;
expertLastName: string;
},
): Promise<boolean> {
return this.sendTemplate({
template:
process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK ||
"yara-blame-expert-lock",
receptor: params.receptor,
token: params.publicId,
token2: params.expertLastName || "کارشناس",
});
}
private async sendTemplate(args: TemplateArgs): Promise<boolean> {
try {
await this.smsGatewayService.verifyLookUp(args);
this.logger.log(
`[SMS] template sent template=${args.template} receptor=${args.receptor}`,
);
return true;
} catch (err) {
this.logger.error(
`[SMS] template send failed template=${args.template} receptor=${args.receptor}: ${describeSmsError(err)}`,
);
return false;
}
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model, Types } from "mongoose";
import { RegistrarModel } from "../schema/registrar.schema";
@Injectable()
export class RegistrarDbService {
constructor(
@InjectModel(RegistrarModel.name)
private readonly registrarModel: Model<RegistrarModel>,
) {}
async create(
user: Partial<RegistrarModel> & { password: string },
): Promise<RegistrarModel> {
return this.registrarModel.create(user);
}
async findOne(
filter: FilterQuery<RegistrarModel>,
): Promise<RegistrarModel | null> {
return this.registrarModel.findOne(filter);
}
async findOneAndUpdate(
filter: FilterQuery<RegistrarModel>,
update: any,
): Promise<RegistrarModel | null> {
return this.registrarModel.findOneAndUpdate(filter, update, { new: true });
}
async updateOne(filter: FilterQuery<RegistrarModel>, update: any) {
return this.registrarModel.updateOne(filter, update);
}
async findById(userId: string): Promise<RegistrarModel | null> {
return this.registrarModel.findOne({ _id: new Types.ObjectId(userId) });
}
}

View File

@@ -0,0 +1,38 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({
collection: "registrar",
versionKey: false,
timestamps: true,
})
export class RegistrarModel {
_id?: Types.ObjectId;
@Prop({ type: "string", unique: true })
email: string;
@Prop({ type: "string" })
username: string;
@Prop({ required: true })
password: string;
@Prop({ type: Types.ObjectId, required: true, index: true })
clientKey: Types.ObjectId;
@Prop({ default: RoleEnum.REGISTRAR })
role: RoleEnum;
@Prop({ type: "string", default: "" })
otp: string;
}
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
RegistrarDbSchema.pre("save", function (next) {
if (!this.username) this.username = this.email;
next();
});

View File

@@ -7,6 +7,7 @@ import { OtpModule } from "src/utils/otp/otp.module";
import { ExpertDbService } from "./entities/db-service/expert.db.service"; import { ExpertDbService } from "./entities/db-service/expert.db.service";
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service"; import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
import { UserDbService } from "./entities/db-service/user.db.service"; import { UserDbService } from "./entities/db-service/user.db.service";
import { import {
DamageExpertDbSchema, DamageExpertDbSchema,
@@ -21,6 +22,10 @@ import {
InsurerExpertDbSchema, InsurerExpertDbSchema,
InsurerExpertModel, InsurerExpertModel,
} from "./entities/schema/insurer-expert.schema"; } from "./entities/schema/insurer-expert.schema";
import {
RegistrarDbSchema,
RegistrarModel,
} from "./entities/schema/registrar.schema";
import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
@Module({ @Module({
@@ -31,6 +36,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
{ name: ExpertModel.name, schema: ExpertDbSchema }, { name: ExpertModel.name, schema: ExpertDbSchema },
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema }, { name: FieldExpertModel.name, schema: FieldExpertDbSchema },
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema }, { name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
]), ]),
OtpModule, OtpModule,
HashModule, HashModule,
@@ -42,6 +48,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService, DamageExpertDbService,
FieldExpertDbService, FieldExpertDbService,
InsurerExpertDbService, InsurerExpertDbService,
RegistrarDbService,
], ],
exports: [ exports: [
UserDbService, UserDbService,
@@ -49,6 +56,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService, DamageExpertDbService,
FieldExpertDbService, FieldExpertDbService,
InsurerExpertDbService, InsurerExpertDbService,
RegistrarDbService,
], ],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -1,15 +0,0 @@
import { KavenegarModule } from "@fraybabak/kavenegar_nest";
import { Module } from "@nestjs/common";
import { SmsManagerService } from "./sms-manager.service";
@Module({
imports: [
KavenegarModule.forRoot({
apikey:
"75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E",
}),
],
providers: [SmsManagerService],
exports: [SmsManagerService],
})
export class SmsManagerModule {}

View File

@@ -1,30 +0,0 @@
import { KavenegarService } from "@fraybabak/kavenegar_nest";
import { Injectable } from "@nestjs/common";
export interface SendMessage {
message: string;
// sender: string;
receptor: string;
}
export interface VerifyLookUpMessage {
template: string;
token: string;
receptor: string;
}
@Injectable()
export class SmsManagerService {
constructor(private readonly sender: KavenegarService) {}
async sendMessage(data: SendMessage) {
return await this.sender.Send({
sender: "10008663",
...data,
});
}
async verifyLookUp(data: VerifyLookUpMessage) {
return await this.sender.verifyLookup(data);
}
}

View File

@@ -102,7 +102,7 @@ export const createClaimWorkflowStepExamples = {
description_en: 'Select other damaged parts (non-body), Sheba number and insurer national code', description_en: 'Select other damaged parts (non-body), Sheba number and insurer national code',
category: 'CLAIM', category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OUTER_PARTS'], requiredPreviousSteps: ['SELECT_OUTER_PARTS'],
nextPossibleSteps: ['UPLOAD_REQUIRED_DOCUMENTS'], nextPossibleSteps: ['CAPTURE_PART_DAMAGES'],
allowedRoles: ['user'], allowedRoles: ['user'],
estimatedDuration: 4, estimatedDuration: 4,
fields: [ fields: [
@@ -188,15 +188,15 @@ export const createClaimWorkflowStepExamples = {
value: { value: {
stepKey: 'UPLOAD_REQUIRED_DOCUMENTS', stepKey: 'UPLOAD_REQUIRED_DOCUMENTS',
type: 'CLAIM', type: 'CLAIM',
stepNumber: 4, stepNumber: 5,
isActive: true, isActive: true,
stepName_fa: 'آپلود مدارک مورد نیاز', stepName_fa: 'آپلود مدارک مورد نیاز',
stepName_en: 'Upload Required Documents', stepName_en: 'Upload Required Documents',
description_fa: 'آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)', description_fa: 'آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)',
description_en: 'Upload required documents for claim review (dynamic list based on selected parts)', description_en: 'Upload required documents for claim review (dynamic list based on selected parts)',
category: 'CLAIM', category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OTHER_PARTS'], requiredPreviousSteps: ['CAPTURE_PART_DAMAGES'],
nextPossibleSteps: ['CAPTURE_PART_DAMAGES'], nextPossibleSteps: ['USER_SUBMISSION_COMPLETE'],
allowedRoles: ['user'], allowedRoles: ['user'],
estimatedDuration: 5, estimatedDuration: 5,
fields: [], fields: [],
@@ -215,15 +215,15 @@ export const createClaimWorkflowStepExamples = {
value: { value: {
stepKey: 'CAPTURE_PART_DAMAGES', stepKey: 'CAPTURE_PART_DAMAGES',
type: 'CLAIM', type: 'CLAIM',
stepNumber: 5, stepNumber: 4,
isActive: true, isActive: true,
stepName_fa: 'ثبت تصاویر آسیب‌های قطعات', stepName_fa: 'ثبت تصاویر آسیب‌های قطعات',
stepName_en: 'Capture Part Damages', stepName_en: 'Capture Part Damages',
description_fa: 'ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)', description_fa: 'ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)',
description_en: 'Capture damage images for each part (one request per part)', description_en: 'Capture damage images for each part (one request per part)',
category: 'CLAIM', category: 'CLAIM',
requiredPreviousSteps: ['UPLOAD_REQUIRED_DOCUMENTS'], requiredPreviousSteps: ['SELECT_OTHER_PARTS'],
nextPossibleSteps: ['USER_SUBMISSION_COMPLETE'], nextPossibleSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
allowedRoles: ['user'], allowedRoles: ['user'],
estimatedDuration: 10, estimatedDuration: 10,
fields: [], fields: [],
@@ -251,7 +251,7 @@ export const createClaimWorkflowStepExamples = {
description_fa: 'اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت', description_fa: 'اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت',
description_en: 'User information completed, waiting for damage expert review', description_en: 'User information completed, waiting for damage expert review',
category: 'CLAIM', category: 'CLAIM',
requiredPreviousSteps: ['CAPTURE_PART_DAMAGES'], requiredPreviousSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
nextPossibleSteps: ['EXPERT_DAMAGE_ASSESSMENT'], nextPossibleSteps: ['EXPERT_DAMAGE_ASSESSMENT'],
allowedRoles: ['user', 'damage_expert', 'admin'], allowedRoles: ['user', 'damage_expert', 'admin'],
estimatedDuration: 0, estimatedDuration: 0,

View File

@@ -128,9 +128,9 @@ export class CreateWorkflowStepDto {
stepKey: WorkflowStep | ClaimWorkflowStep; stepKey: WorkflowStep | ClaimWorkflowStep;
@ApiProperty({ @ApiProperty({
description: 'Workflow type: THIRD_PARTY (blame) or CLAIM (claim workflow)', description: 'Workflow type: THIRD_PARTY (blame), CAR_BODY (blame), or CLAIM (claim workflow)',
example: 'THIRD_PARTY', example: 'THIRD_PARTY',
enum: ['THIRD_PARTY', 'CLAIM'] enum: ['THIRD_PARTY', 'CAR_BODY', 'CLAIM']
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()

View File

@@ -1158,7 +1158,7 @@ export const claimWorkflowStepExamples = {
"description_en": "Select other damaged parts (non-body), Sheba number and insurer national code", "description_en": "Select other damaged parts (non-body), Sheba number and insurer national code",
"category": "CLAIM", "category": "CLAIM",
"requiredPreviousSteps": ["SELECT_OUTER_PARTS"], "requiredPreviousSteps": ["SELECT_OUTER_PARTS"],
"nextPossibleSteps": ["UPLOAD_REQUIRED_DOCUMENTS"], "nextPossibleSteps": ["CAPTURE_PART_DAMAGES"],
"allowedRoles": ["user"], "allowedRoles": ["user"],
"estimatedDuration": 4, "estimatedDuration": 4,
"fields": [ "fields": [
@@ -1240,19 +1240,19 @@ export const claimWorkflowStepExamples = {
}, },
uploadRequiredDocuments: { uploadRequiredDocuments: {
summary: 'Claim Step 4: UPLOAD_REQUIRED_DOCUMENTS', summary: 'Claim Step 5: UPLOAD_REQUIRED_DOCUMENTS',
value: { value: {
"stepKey": "UPLOAD_REQUIRED_DOCUMENTS", "stepKey": "UPLOAD_REQUIRED_DOCUMENTS",
"type": "CLAIM", "type": "CLAIM",
"stepNumber": 4, "stepNumber": 5,
"isActive": true, "isActive": true,
"stepName_fa": "آپلود مدارک مورد نیاز", "stepName_fa": "آپلود مدارک مورد نیاز",
"stepName_en": "Upload Required Documents", "stepName_en": "Upload Required Documents",
"description_fa": "آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)", "description_fa": "آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)",
"description_en": "Upload required documents for claim review (dynamic list based on selected parts)", "description_en": "Upload required documents for claim review (dynamic list based on selected parts)",
"category": "CLAIM", "category": "CLAIM",
"requiredPreviousSteps": ["SELECT_OTHER_PARTS"], "requiredPreviousSteps": ["CAPTURE_PART_DAMAGES"],
"nextPossibleSteps": ["CAPTURE_PART_DAMAGES"], "nextPossibleSteps": ["USER_SUBMISSION_COMPLETE"],
"allowedRoles": ["user"], "allowedRoles": ["user"],
"estimatedDuration": 5, "estimatedDuration": 5,
"fields": [], "fields": [],
@@ -1266,19 +1266,19 @@ export const claimWorkflowStepExamples = {
}, },
capturePartDamages: { capturePartDamages: {
summary: 'Claim Step 5: CAPTURE_PART_DAMAGES', summary: 'Claim Step 4: CAPTURE_PART_DAMAGES',
value: { value: {
"stepKey": "CAPTURE_PART_DAMAGES", "stepKey": "CAPTURE_PART_DAMAGES",
"type": "CLAIM", "type": "CLAIM",
"stepNumber": 5, "stepNumber": 4,
"isActive": true, "isActive": true,
"stepName_fa": "ثبت تصاویر آسیب‌های قطعات", "stepName_fa": "ثبت تصاویر آسیب‌های قطعات",
"stepName_en": "Capture Part Damages", "stepName_en": "Capture Part Damages",
"description_fa": "ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)", "description_fa": "ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)",
"description_en": "Capture damage images for each part (one request per part)", "description_en": "Capture damage images for each part (one request per part)",
"category": "CLAIM", "category": "CLAIM",
"requiredPreviousSteps": ["UPLOAD_REQUIRED_DOCUMENTS"], "requiredPreviousSteps": ["SELECT_OTHER_PARTS"],
"nextPossibleSteps": ["USER_SUBMISSION_COMPLETE"], "nextPossibleSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"allowedRoles": ["user"], "allowedRoles": ["user"],
"estimatedDuration": 10, "estimatedDuration": 10,
"fields": [], "fields": [],
@@ -1305,7 +1305,7 @@ export const claimWorkflowStepExamples = {
"description_fa": "اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت", "description_fa": "اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت",
"description_en": "User information completed, waiting for damage expert review", "description_en": "User information completed, waiting for damage expert review",
"category": "CLAIM", "category": "CLAIM",
"requiredPreviousSteps": ["CAPTURE_PART_DAMAGES"], "requiredPreviousSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"nextPossibleSteps": ["EXPERT_DAMAGE_ASSESSMENT"], "nextPossibleSteps": ["EXPERT_DAMAGE_ASSESSMENT"],
"allowedRoles": ["user", "damage_expert", "admin"], "allowedRoles": ["user", "damage_expert", "admin"],
"estimatedDuration": 0, "estimatedDuration": 0,
@@ -1539,3 +1539,24 @@ export const claimWorkflowStepExamples = {
} }
} }
}; };
/**
* Blame V2 expert resend: same `stepKey`s as the initial THIRD_PARTY blame flow
* (`FIRST_VOICE` → `FIRST_DESCRIPTION` → `SECOND_VOICE` → `SECOND_DESCRIPTION`).
* Seed workflow steps once via POST bodies below (or reuse existing rows). Resend only stores
* ordered keys on `blameCase.expert.resend` — clients resolve full field defs from `workflowSteps`.
*/
export const blameResendWorkflowStepExamples = {
firstVoice: createWorkflowStepExamples.example6,
firstDescription: createWorkflowStepExamples.example7,
secondVoice: createWorkflowStepExamples.example11,
secondDescription: createWorkflowStepExamples.example12,
stepKeysInOrder: [
'FIRST_VOICE',
'FIRST_DESCRIPTION',
'SECOND_VOICE',
'SECOND_DESCRIPTION',
] as const,
note:
'Override per party with PartyResendRequestDto.requiredWorkflowStepKeys or ResendItemType.VOICE + DESCRIPTION when calling expert resend V2.',
} as const;

View File

@@ -1123,6 +1123,43 @@ export class WorkflowStepManagementService {
party: 'second' party: 'second'
} }
}, },
// CAR_BODY workflow: accident type form (replaces confession for CAR_BODY)
{
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
type: 'CAR_BODY',
stepNumber: 2,
isActive: true,
stepName_fa: 'نوع حادثه بدنه',
stepName_en: 'Car Body Accident Type',
description_fa: 'انتخاب اینکه حادثه با خودرو بوده یا با شیء',
description_en: 'Select whether accident was with another car or with an object',
category: 'FIRST_PARTY',
requiredPreviousSteps: [WorkflowStep.CREATED],
nextPossibleSteps: [WorkflowStep.FIRST_VIDEO],
allowedRoles: ['user'],
estimatedDuration: 1,
fields: [
{
id: '507f1f77bcf86cd7994390cb' as any,
name_fa: 'نوع حادثه',
name_en: 'accidentType',
label_fa: 'نوع حادثه',
label_en: 'Accident Type',
placeholder_fa: 'حادثه با خودرو یا شیء؟',
placeholder_en: 'Accident with car or object?',
value: [
{ label_fa: 'با خودرو', label_en: 'With Car', value: 'car' },
{ label_fa: 'با شیء', label_en: 'With Object', value: 'object' }
],
dataType: 'select',
required: true,
order: 1,
isActive: true,
validation: { enum: ['car', 'object'] }
}
],
metadata: { icon: 'car', color: '#10B981' }
},
// Add more default steps as needed... // Add more default steps as needed...
]; ];