Compare commits

..

28 Commits

Author SHA1 Message Date
4bdb9fd469 update some important issues 2026-04-18 10:49:22 +03:30
Soheil Hajizadeh
494e3d93ab update the expert claim dto 2026-04-11 09:46:54 +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
61 changed files with 7461 additions and 1890 deletions

2848
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@@ -1,19 +1,21 @@
//! NEW //! NEW
export enum CaseStatus { export enum CaseStatus {
OPEN = "OPEN", OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED" AUTO_CLOSED = "AUTO_CLOSED",
}
STOPPED = "STOPPED"
}

View File

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

@@ -9,4 +9,7 @@ export enum ResendItemType {
// 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

@@ -18,6 +18,8 @@ export enum ClaimWorkflowStep {
// 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,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

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

@@ -14,6 +14,7 @@ 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 { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
@@ -71,6 +72,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 +97,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 +110,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 +118,26 @@ export class UserAuthService {
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
return this.smsManagerService try {
.verifyLookUp({ await this.smsManagerService.verifyLookUp({
token: otp, token: otp,
template: process.env.AUTH_SMS_TEMPLATE, template: process.env.AUTH_SMS_TEMPLATE,
receptor: mobile, 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} `,
);
throw new HttpException(
" auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}); });
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
);
} catch (err) {
this.logger.error(
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
);
if (err instanceof HttpException) {
throw err;
}
throw new HttpException(
"auth sms send failed",
HttpStatus.BAD_GATEWAY,
);
}
} }
} }

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

@@ -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(
@@ -91,6 +98,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 +108,89 @@ export class ClaimRequestManagementV2Controller {
} }
} }
/**
* 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 +322,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;
@@ -345,6 +437,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;
@@ -397,6 +490,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;
@@ -434,16 +528,16 @@ Returns status of each item (uploaded/captured or not).
description: ` description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim) **Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
**Upload one of the 13 required documents:** **Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card - 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 CAPTURE_PART_DAMAGES (Step 5).
- 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({
@@ -507,6 +601,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;
@@ -547,6 +642,8 @@ Returns status of each item (uploaded/captured or not).
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.** **All captures must be completed before user can submit claim.**
**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 +695,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 +704,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

@@ -79,3 +79,26 @@ export class CapturePartV2ResponseDto {
}) })
message: string; message: string;
} }
/**
* 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

@@ -63,6 +63,15 @@ 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: 'User satisfaction rating (if submitted)' })
userRating?: {
progressSpeed: number;
registrationEase: number;
overallEvaluation: number;
comment?: string;
createdAt?: Date;
};
@ApiProperty({ description: 'Created at' }) @ApiProperty({ description: 'Created at' })
createdAt: string; createdAt: string;

View File

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

@@ -87,8 +87,9 @@ export class ClaimExpertReply {
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 +104,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 {
@@ -182,8 +221,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;

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

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

@@ -25,6 +25,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 +33,7 @@ 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";
interface CheckedRequestEntry { interface CheckedRequestEntry {
CheckedRequest?: { CheckedRequest?: {
@@ -58,6 +60,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,7 +73,8 @@ 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,
) { }
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.
@@ -186,7 +193,7 @@ export class ExpertBlameService {
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> { async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try { try {
const expertId = actor.sub; const expertId = actor.sub;
// Fetch all DISAGREEMENT cases // Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find( const allCases = await this.blameRequestDbService.find(
{ {
@@ -198,7 +205,18 @@ export class ExpertBlameService {
// Filter to show only: // Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision) // 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert // 2. Requests decided by current expert
// 3. 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) => {
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 +241,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 +325,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 {
@@ -394,9 +510,9 @@ export class ExpertBlameService {
// 2. Initial validation to ensure the expert has access // 2. Initial validation to ensure the expert has access
// Check if locked by current expert and lock is still active // Check if locked by current expert and lock is still active
const isLockedByCurrentExpert = const isLockedByCurrentExpert =
String(request?.actorLocked?.actorId) === actorId && request.lockFile; String(request?.actorLocked?.actorId) === actorId && request.lockFile;
// Check if lock has expired // Check if lock has expired
let isLockExpired = false; let isLockExpired = false;
if (request.unlockTime) { if (request.unlockTime) {
@@ -404,7 +520,7 @@ export class ExpertBlameService {
const now = Date.now(); const now = Date.now();
isLockExpired = now >= unlockTime; isLockExpired = now >= unlockTime;
} }
if (isLockedByCurrentExpert && !isLockExpired) { if (isLockedByCurrentExpert && !isLockExpired) {
// This is the correct expert, and the file is locked to them, which is fine. // This is the correct expert, and the file is locked to them, which is fine.
// They can access it even if they closed the browser and came back. // They can access it even if they closed the browser and came back.
@@ -415,7 +531,7 @@ export class ExpertBlameService {
// The file is locked by someone else, or lock expired but status hasn't updated yet // The file is locked by someone else, or lock expired but status hasn't updated yet
// Only block if lock is still active and not by current expert // Only block if lock is still active and not by current expert
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) { if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
throw new BadRequestException("Request is locked by another expert"); throw new BadRequestException("Request is locked by another expert");
} }
} }
@@ -509,12 +625,100 @@ 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>> {
// try {
// const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
// if (!doc) {
// throw new NotFoundException("Request not found");
// }
// const type = doc.type as string;
// if (type === BlameRequestType.CAR_BODY) {
// throw new ForbiddenException(
// "CAR_BODY type requests are automatically handled and do not require expert review.",
// );
// }
// // Access control: Expert-initiated files only visible to the initiating field expert
// 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.",
// );
// }
// // Allow if:
// // 1. No decision yet (fresh request)
// // 2. Decision made by current expert
// const decision = (doc.expert as any)?.decision;
// const decidedByExpertId = decision?.decidedByExpertId;
// if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
// throw new ForbiddenException(
// "You do not have permission to view this request. It has been handled by another expert.",
// );
// }
// const parties = (doc.parties ?? []) as Array<{
// evidence?: { videoId?: string; voices?: string[] };
// [key: string]: unknown;
// }>;
// for (const party of parties) {
// if (!party.evidence) continue;
// const evidence = party.evidence as Record<string, unknown>;
// if (evidence.videoId) {
// const videoDoc = await this.blameVideoDbService.findById(
// String(evidence.videoId),
// );
// if (videoDoc?.path) {
// evidence.videoUrl = buildFileLink(videoDoc.path);
// }
// }
// if (evidence.voices && Array.isArray(evidence.voices)) {
// const voiceUrls: string[] = [];
// for (const voiceId of evidence.voices) {
// const voiceDoc = await this.blameVoiceDbService.findById(
// String(voiceId),
// );
// if (voiceDoc?.path) {
// voiceUrls.push(buildFileLink(voiceDoc.path));
// }
// }
// evidence.voiceUrls = voiceUrls;
// }
// }
// const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date();
// const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date();
// const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
// const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
// doc.createdAtFormatted = `${createdDate} ${createdTime}`;
// doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// // Exclude mapped inquiry vehicle for both parties from response
// for (const party of parties) {
// delete party.vehicle;
// }
// return doc;
// } catch (error) {
// if (error instanceof HttpException) throw error;
// this.logger.error(
// "findOneV2 failed",
// requestId,
// error instanceof Error ? error.stack : String(error),
// );
// throw new InternalServerErrorException(
// error instanceof Error ? error.message : "Failed to get blame case details",
// );
// }
// }
async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> { async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
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");
}
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,74 +726,100 @@ 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 = (doc.parties ?? []);
evidence?: { videoId?: string; voices?: string[] };
[key: string]: unknown; const typedParties = parties as Array<{
vehicle?: {
inquiry?: {
mapped?: any; // Use 'any' or a more specific type if known
// other inquiry properties
};
// other vehicle properties
};
evidence?: { // For the second part of your original error description
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
// other party properties
}>; }>;
for (const party of parties) {
// Extract inquiry.mapped for each party if needed, but we will keep the full vehicle object.
for (const party of typedParties) {
const mapped = party?.vehicle?.inquiry?.mapped;
// If you still want this field, you can add it like:
// party.inquiryMapped = mapped ?? null;
// But based on your last request, we are keeping the full vehicle object.
}
// 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 // The line `delete party.vehicle;` has been removed, so vehicle will be included.
for (const party of parties) {
delete party.vehicle;
}
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 fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
@@ -642,8 +872,8 @@ 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) {
@@ -660,11 +890,23 @@ 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);
@@ -727,8 +969,18 @@ export class ExpertBlameService {
requestId: string, requestId: string,
resendDto: ResendRequestDto, resendDto: ResendRequestDto,
actorId: string, actorId: string,
): 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);
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
@@ -752,7 +1004,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 +1027,42 @@ export class ExpertBlameService {
); );
} }
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),
);
for (const party of resendDto.parties) {
const uid = 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 now = new Date();
// Build resend requests for parties const partyResendRequests = resendDto.parties.map((party) => {
const partyResendRequests = resendDto.parties.map((party) => ({ const uid = String(party.partyId);
partyId: new Types.ObjectId(String(party.partyId)), return {
requestedItems: party.requestedItems, partyId: new Types.ObjectId(uid),
description: party.description || "", requestedItems: party.requestedItems,
requestedAt: now, description: party.description || "",
completed: false, requestedAt: now,
})); completed: false,
uploadedDocuments: {},
};
});
const updatePayload = { const updatePayload = {
$set: { $set: {
@@ -797,9 +1076,6 @@ export class ExpertBlameService {
}, },
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 +1093,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,6 +1125,12 @@ 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,
@@ -846,6 +1138,7 @@ export class ExpertBlameService {
actorId: string, actorId: string,
): Promise<{ requestId: string; status: string }> { ): Promise<{ requestId: string; status: string }> {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
@@ -873,7 +1166,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;
} }
@@ -968,6 +1262,126 @@ 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();
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: null,
description: "حضوری مراجعه شود.",
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
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,
@@ -1040,7 +1454,7 @@ export class ExpertBlameService {
"Access denied to this request. You are not the locked expert.", "Access denied to this request. You are not the locked expert.",
); );
} }
// Check if lock has expired (unlockTime has passed) // Check if lock has expired (unlockTime has passed)
if (request.unlockTime) { if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime(); const unlockTime = new Date(request.unlockTime).getTime();
@@ -1051,7 +1465,7 @@ export class ExpertBlameService {
} else if (request.unlockTime == null) { } else if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired."); throw new ForbiddenException("Your lock time has expired.");
} }
if (!request.lockFile) { if (!request.lockFile) {
throw new ForbiddenException( throw new ForbiddenException(
"You must lock the request before submitting a reply.", "You must lock the request before submitting a reply.",
@@ -1302,4 +1716,5 @@ export class ExpertBlameService {
return updated; return updated;
} }
} }

View File

@@ -22,9 +22,9 @@ 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) { }
@Get() @Get()
async findAll(@CurrentUser() actor: any) { async findAll(@CurrentUser() actor: any) {
@@ -101,4 +101,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

@@ -69,6 +69,21 @@ 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;
};
@ApiProperty() @ApiProperty()
createdAt: string; createdAt: string;

View File

@@ -31,6 +31,12 @@ export class ClaimListItemV2Dto {
@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

@@ -8,6 +8,8 @@ import {
ValidateNested, ValidateNested,
} 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';
export class CarPartDamageV2Dto { export class CarPartDamageV2Dto {
@ApiProperty({ example: 'left' }) @ApiProperty({ example: 'left' })
@@ -68,3 +70,14 @@ export class SubmitExpertReplyV2Dto {
@Type(() => PartPricingV2Dto) @Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[]; parts: PartPricingV2Dto[];
} }
export class ClaimSubmitResendV2Dto {
@ApiProperty({ required: true })
resendDescription: string;
@ApiProperty({ required: true, examples: ClaimRequiredDocumentType })
resendDocuments: ClaimRequiredDocumentType[];
@ApiProperty({ required: 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[];
}

View File

@@ -4,6 +4,7 @@ import { join } from "node:path";
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
HttpException, HttpException,
HttpStatus, HttpStatus,
@@ -36,11 +37,15 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto"; import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto"; import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto"; import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service"; import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
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 { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
@Injectable() @Injectable()
export class ExpertClaimService { export class ExpertClaimService {
@@ -153,13 +158,14 @@ export class ExpertClaimService {
private readonly claimVideoCaptureDbService: VideoCaptureDbService, private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly sandHubService: SandHubService, private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService, private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly branchDbService: BranchDbService, private readonly branchDbService: BranchDbService,
) {} ) { }
private isVisibleToClientType(client: any, actor: any): boolean { private isVisibleToClientType(client: any, actor: any): boolean {
if (actor.userType === UserType.GENUINE) { if (actor.userType === UserType.GENUINE) {
@@ -473,7 +479,7 @@ export class ExpertClaimService {
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) { async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
const requestId = request?._id?.toString() || 'unknown'; const requestId = request?._id?.toString() || 'unknown';
try { try {
// Step 1: Check SandHub data // Step 1: Check SandHub data
if (!request?.blameFile?.sanHubId) { if (!request?.blameFile?.sanHubId) {
@@ -531,7 +537,7 @@ export class ExpertClaimService {
} else if (severityValues.length > 0) { } else if (severityValues.length > 0) {
// Only fetch car prices if we have severity values to calculate // Only fetch car prices if we have severity values to calculate
const carPrices = await this.fetchCarPrices(); const carPrices = await this.fetchCarPrices();
if (!carPrices || carPrices.length === 0) { if (!carPrices || carPrices.length === 0) {
this.logger.error( this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`, `[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
@@ -541,9 +547,9 @@ export class ExpertClaimService {
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`, `[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
); );
const closestCar = this.findClosestCar( const closestCar = this.findClosestCar(
request.carDetail.carName, request.carDetail.carName,
carPrices, carPrices,
); );
if (!closestCar) { if (!closestCar) {
@@ -557,11 +563,11 @@ export class ExpertClaimService {
} else { } else {
this.logger.debug( this.logger.debug(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`, `[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
); );
autoCalculatedData = { autoCalculatedData = {
carPrice: closestCar.marketPrice, carPrice: closestCar.marketPrice,
carValue: severityValues, carValue: severityValues,
}; };
} }
} }
} }
@@ -599,10 +605,10 @@ export class ExpertClaimService {
} }
// Step 6: Save to database // Step 6: Save to database
await this.claimRequestManagementDbService.findOneAndUpdate( await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(request._id) }, { _id: new Types.ObjectId(request._id) },
{ $set: { priceDrop: finalPriceDropData } }, { $set: { priceDrop: finalPriceDropData } },
); );
this.logger.log( this.logger.log(
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`, `[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
@@ -670,7 +676,7 @@ export class ExpertClaimService {
const severityValue = const severityValue =
this.priceDropPart[originalPriceDropKey]?.[ this.priceDropPart[originalPriceDropKey]?.[
damagedPartInfo.severity damagedPartInfo.severity
]; ];
if (severityValue !== undefined) { if (severityValue !== undefined) {
@@ -967,13 +973,13 @@ export class ExpertClaimService {
private isCurrentUserAllowed(request: any, currentUser: any): boolean { private isCurrentUserAllowed(request: any, currentUser: any): boolean {
// Check if locked by current user and lock is still active // Check if locked by current user and lock is still active
const isLockedByCurrentUser = const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub && String(request?.actorLocked?.actorId) === currentUser.sub &&
request.lockFile; request.lockFile;
if (!isLockedByCurrentUser) { if (!isLockedByCurrentUser) {
return false; return false;
} }
// Also check if lock has expired (unlockTime has passed) // Also check if lock has expired (unlockTime has passed)
if (request.unlockTime) { if (request.unlockTime) {
@@ -985,7 +991,7 @@ export class ExpertClaimService {
return true; return true;
} }
} }
return true; return true;
} }
@@ -993,7 +999,7 @@ export class ExpertClaimService {
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) { if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
return false; return false;
} }
// Check if lock has expired // Check if lock has expired
if (request.unlockTime) { if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime(); const unlockTime = new Date(request.unlockTime).getTime();
@@ -1003,15 +1009,15 @@ export class ExpertClaimService {
return false; return false;
} }
} }
// If currentUser is provided, allow access if they are the one who locked it // If currentUser is provided, allow access if they are the one who locked it
if (currentUser) { if (currentUser) {
const isLockedByCurrentUser = const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub; String(request?.actorLocked?.actorId) === currentUser.sub;
// Return false (not locked) if locked by current user, true if locked by someone else // Return false (not locked) if locked by current user, true if locked by someone else
return !isLockedByCurrentUser; return !isLockedByCurrentUser;
} }
// If no currentUser provided, treat as locked // If no currentUser provided, treat as locked
return true; return true;
} }
@@ -1034,18 +1040,18 @@ export class ExpertClaimService {
) { ) {
throw new ForbiddenException("Access denied to this request"); throw new ForbiddenException("Access denied to this request");
} }
// Check if lock has expired (unlockTime has passed) // Check if lock has expired (unlockTime has passed)
if (request?.unlockTime && !request?.objection) { if (request?.unlockTime && !request?.objection) {
const unlockTime = new Date(request.unlockTime).getTime(); const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now(); const now = Date.now();
if (now >= unlockTime) { if (now >= unlockTime) {
throw new ForbiddenException("Your time has expired"); throw new ForbiddenException("Your time has expired");
} }
} else if (request?.unlockTime == null && !request?.objection) { } else if (request?.unlockTime == null && !request?.objection) {
throw new ForbiddenException("Your time has expired"); throw new ForbiddenException("Your time has expired");
} }
if (!request.lockFile && !request?.objection) { if (!request.lockFile && !request?.objection) {
throw new ForbiddenException( throw new ForbiddenException(
"For submit reply you must lock the request", "For submit reply you must lock the request",
@@ -1254,6 +1260,66 @@ export class ExpertClaimService {
return request; return request;
} }
async streamServiceV2(requestId, query, res, header) {
const request = await this.claimCaseDbService.findById(requestId);
console.log(request)
// const blameCase = await this.blameCaseDbService
return request;
// let videoPath: string = "";
// switch (query) {
// case "accident":
// videoPath = await this.getAccidentVideoPath(
// String(
// request?.blameFile?.firstPartyDetails?.firstPartyFile
// ?.firstPartyVideoId,
// ),
// );
// break;
// case "car-capture":
// videoPath = await this.getCarCapture(requestId);
// break;
// default:
// throw new NotFoundException("Invalid query type");
// }
// if (!videoPath) throw new NotFoundException("video_not_found");
// const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
// if (!existsSync(absolutePath)) {
// throw new NotFoundException(
// `Video file not found at path: ${absolutePath}`,
// );
// }
// const { size } = statSync(absolutePath);
// const range = header.range;
// if (range) {
// const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
// const start = parseInt(startStr, 10);
// const end = endStr ? parseInt(endStr, 10) : size - 1;
// const chunkSize = end - start + 1;
// const file = createReadStream(absolutePath, { start, end });
// res.writeHead(206, {
// "Content-Range": `bytes ${start}-${end}/${size}`,
// "Accept-Ranges": "bytes",
// "Content-Length": chunkSize,
// "Content-Type": "video/mp4",
// });
// file.pipe(res);
// } else {
// res.writeHead(200, {
// "Content-Length": size,
// "Content-Type": "video/mp4",
// });
// createReadStream(absolutePath).pipe(res);
// }
}
async streamService(requestId, query, res, header) { async streamService(requestId, query, res, header) {
const request = const request =
await this.claimRequestManagementDbService.findOne(requestId); await this.claimRequestManagementDbService.findOne(requestId);
@@ -1527,6 +1593,208 @@ export class ExpertClaimService {
}; };
} }
/**
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW.
* — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization).
*/
async validateClaimFactorsV2(
claimRequestId: string,
body: FactorValidationV2Dto,
actor: { sub: string; fullName?: string },
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
if (
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
claim.workflow?.currentStep !== ClaimWorkflowStep.EXPERT_COST_EVALUATION
) {
throw new BadRequestException(
"Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
);
}
const replyField = claim.evaluation?.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
const finalReply =
replyField === "damageExpertReplyFinal"
? claim.evaluation?.damageExpertReplyFinal
: claim.evaluation?.damageExpertReply;
if (!finalReply?.parts?.length) {
throw new BadRequestException("No expert reply found in this claim.");
}
const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded);
if (requiredFactors.length === 0) {
throw new BadRequestException("No parts require factor validation.");
}
if (!requiredFactors.every((p) => !!p.factorLink)) {
throw new BadRequestException(
"Not all required factors have been uploaded yet.",
);
}
const $set: Record<string, unknown> = {};
for (const decision of body.decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => String(p.partId) === String(decision.partId),
);
if (partIndex === -1) {
this.logger.warn(
`Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`,
);
continue;
}
const part = finalReply.parts[partIndex];
if (!part.factorNeeded) {
this.logger.warn(
`Part ${decision.partId} is not factor-needed, skipping.`,
);
continue;
}
const base = `evaluation.${replyField}.parts.${partIndex}`;
$set[`${base}.factorStatus`] = decision.status;
$set[`${base}.rejectionReason`] = decision.rejectionReason ?? null;
if (decision.price !== undefined) {
$set[`${base}.price`] = decision.price;
}
if (decision.salary !== undefined) {
$set[`${base}.salary`] = decision.salary;
}
if (decision.totalPayment !== undefined) {
$set[`${base}.totalPayment`] = decision.totalPayment;
}
if (decision.status === FactorStatus.REJECTED) {
const hasOverride =
decision.totalPayment != null ||
(decision.price != null && decision.salary != null);
if (!hasOverride) {
throw new BadRequestException(
`Part ${decision.partId}: rejected factors require expert totalPayment (or both price and salary).`,
);
}
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No valid factor decisions to apply.");
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
});
const updatedClaim = await this.claimCaseDbService.findById(claimRequestId);
const updatedReply =
replyField === "damageExpertReplyFinal"
? updatedClaim!.evaluation!.damageExpertReplyFinal!
: updatedClaim!.evaluation!.damageExpertReply!;
const reqParts = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = reqParts.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = reqParts.some(
(p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING,
);
const baseMessage = "Factor validation updated.";
if (anyStillPending) {
return {
message: `${baseMessage} Some factors are still pending review.`,
claimRequestId,
claimStatus: updatedClaim!.claimStatus,
caseStatus: updatedClaim!.status,
pendingFactorValidation: true,
};
}
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
if (!isNaN(parsed)) {
totalPrice += parsed;
}
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap: PRICE_CAP,
});
}
const historyActor = {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
};
if (anyRejected) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
history: {
type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED",
actor: historyActor,
timestamp: new Date(),
metadata: { replyField },
},
},
});
return {
message:
"Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_COMPLETED",
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
history: {
type: "FACTORS_VALIDATED_ALL_APPROVED",
actor: historyActor,
timestamp: new Date(),
metadata: { replyField },
},
},
});
return {
message:
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
outcome: "ALL_APPROVED_INSURER_REVIEW",
};
}
async inPersonVisit(requestId: string, actorDetail: any) { async inPersonVisit(requestId: string, actorDetail: any) {
const request = const request =
await this.claimRequestManagementDbService.findOne(requestId); await this.claimRequestManagementDbService.findOne(requestId);
@@ -1634,6 +1902,78 @@ export class ExpertClaimService {
}; };
} }
async submitResendDocsV2(
claimRequestId: string,
reply: ClaimSubmitResendV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a resend request',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
const existingResend = claim.evaluation?.damageExpertResend;
const resendHasContent =
existingResend &&
(String(existingResend.resendDescription || '').trim() !== '' ||
(existingResend.resendDocuments?.length ?? 0) > 0 ||
(existingResend.resendCarParts?.length ?? 0) > 0);
if (resendHasContent) {
throw new ConflictException('A resend request already exists for this claim');
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
'evaluation.damageExpertResend': {
resendDescription: reply.resendDescription,
resendDocuments: reply.resendDocuments ?? [],
resendCarParts: reply.resendCarParts ?? [],
},
},
$push: {
history: {
type: 'EXPERT_RESEND_REQUESTED',
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: 'damage_expert',
},
timestamp: new Date(),
metadata: {
documentCount: reply.resendDocuments?.length ?? 0,
carPartCount: reply.resendCarParts?.length ?? 0,
},
},
},
});
return {
claimRequestId,
claimStatus: ClaimStatus.NEEDS_REVISION,
message:
'Resend request recorded. The damaged party can review requirements and submit an objection or updated materials.',
};
}
/** /**
* V2: Submit damage expert reply for a claim. * V2: Submit damage expert reply for a claim.
* *
@@ -1694,6 +2034,23 @@ export class ExpertClaimService {
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false; const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
if (objectionSubmitted && hasFinalReply) {
throw new ConflictException(
'A final expert reply after objection already exists for this claim.',
);
}
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
const replyField = isFinalReplyAfterObjection
? 'damageExpertReplyFinal'
: 'damageExpertReply';
const completedStep = isFinalReplyAfterObjection
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const replyPayload = { const replyPayload = {
description: reply.description, description: reply.description,
parts: reply.parts, parts: reply.parts,
@@ -1713,7 +2070,7 @@ export class ExpertClaimService {
? ClaimStatus.NEEDS_REVISION ? ClaimStatus.NEEDS_REVISION
: ClaimStatus.APPROVED; : ClaimStatus.APPROVED;
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { const updatePayload: Record<string, unknown> = {
status: nextCaseStatus, status: nextCaseStatus,
claimStatus: nextClaimStatus, claimStatus: nextClaimStatus,
'workflow.locked': false, 'workflow.locked': false,
@@ -1721,18 +2078,30 @@ export class ExpertClaimService {
'workflow.nextStep': needsFactorUpload 'workflow.nextStep': needsFactorUpload
? ClaimWorkflowStep.INSURER_REVIEW ? ClaimWorkflowStep.INSURER_REVIEW
: ClaimWorkflowStep.CLAIM_COMPLETED, : ClaimWorkflowStep.CLAIM_COMPLETED,
'evaluation.damageExpertReply': replyPayload, [`evaluation.${replyField}`]: replyPayload,
$push: { $push: {
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.completedSteps': completedStep,
history: { history: {
event: 'EXPERT_REPLY_SUBMITTED', type: isFinalReplyAfterObjection
performedBy: actor.sub, ? 'EXPERT_FINAL_REPLY_SUBMITTED'
performedByName: actor.fullName, : 'EXPERT_REPLY_SUBMITTED',
performedAt: new Date(), actor: {
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`, actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: 'damage_expert',
},
timestamp: new Date(),
metadata: {
factorNeeded: needsFactorUpload,
partsCount: reply.parts?.length ?? 0,
isFinalReplyAfterObjection,
replyField,
},
}, },
}, },
}); };
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
return { return {
claimRequestId, claimRequestId,
@@ -1740,6 +2109,7 @@ export class ExpertClaimService {
claimStatus: nextClaimStatus, claimStatus: nextClaimStatus,
currentStep: nextStep, currentStep: nextStep,
factorNeeded: needsFactorUpload, factorNeeded: needsFactorUpload,
isFinalReplyAfterObjection,
}; };
} }
@@ -1827,30 +2197,43 @@ export class ExpertClaimService {
'workflow.locked': true, 'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId), 'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
}, },
// User uploaded all factors; expert must approve/reject (unlocked queue)
{
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
], ],
}); });
const list = (claims as any[]).map((c) => ({ const list = (claims as any[]).map((c) => {
claimRequestId: c._id.toString(), const awaitingFactorValidation =
publicId: c.publicId, c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
status: c.status, c.claimStatus === ClaimStatus.UNDER_REVIEW &&
currentStep: c.workflow?.currentStep || '', c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
locked: c.workflow?.locked || false, return {
lockedBy: c.workflow?.lockedBy claimRequestId: c._id.toString(),
? { publicId: c.publicId,
status: c.status,
currentStep: c.workflow?.currentStep || '',
locked: c.workflow?.locked || false,
lockedBy: c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(), actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName, actorName: c.workflow.lockedBy.actorName,
} }
: undefined, : undefined,
vehicle: c.vehicle vehicle: c.vehicle
? { ? {
carName: c.vehicle.carName, carName: c.vehicle.carName,
carModel: c.vehicle.carModel, carModel: c.vehicle.carModel,
carType: c.vehicle.carType, carType: c.vehicle.carType,
} }
: undefined, : undefined,
createdAt: c.createdAt, createdAt: c.createdAt,
})) as ClaimListItemV2Dto[]; awaitingFactorValidation,
};
}) as ClaimListItemV2Dto[];
return { list, total: list.length }; return { list, total: list.length };
} }
@@ -1873,14 +2256,22 @@ export class ExpertClaimService {
throw new NotFoundException('Claim request not found'); throw new NotFoundException('Claim request not found');
} }
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { const isPickupReview =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
const isFactorValidationPending =
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
if (!isPickupReview && !isFactorValidationPending) {
throw new ForbiddenException( throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`, `This claim is not available for expert review. Current status: ${claim.status}`,
); );
} }
// If locked by someone else, deny access // If locked by someone else, deny access (initial pickup / assessment only)
if ( if (
isPickupReview &&
claim.workflow?.locked && claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actorId claim.workflow.lockedBy?.actorId?.toString() !== actorId
) { ) {
@@ -1943,16 +2334,16 @@ export class ExpertClaimService {
locked: claim.workflow?.locked || false, locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy lockedBy: claim.workflow?.lockedBy
? { ? {
actorId: claim.workflow.lockedBy.actorId?.toString(), actorId: claim.workflow.lockedBy.actorId?.toString(),
actorName: claim.workflow.lockedBy.actorName, actorName: claim.workflow.lockedBy.actorName,
lockedAt: (claim.workflow as any).lockedAt?.toISOString(), lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
} }
: undefined, : undefined,
owner: claim.owner owner: claim.owner
? { ? {
userId: claim.owner.userId?.toString(), userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName, fullName: claim.owner.fullName,
} }
: undefined, : undefined,
vehicle: claim.vehicle, vehicle: claim.vehicle,
blameRequestId: claim.blameRequestId?.toString(), blameRequestId: claim.blameRequestId?.toString(),
@@ -1965,8 +2356,88 @@ export class ExpertClaimService {
: undefined, : undefined,
carAngles, carAngles,
damagedParts, damagedParts,
awaitingFactorValidation: isFactorValidationPending,
evaluation: isFactorValidationPending
? {
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
}
: undefined,
createdAt: (claim as any).createdAt, createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt, updatedAt: (claim as any).updatedAt,
}; };
} }
/**
* V2: Damage expert can modify selected damaged parts before final submit.
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
*/
async updateClaimDamagedPartsV2(
claimRequestId: string,
body: UpdateClaimDamagedPartsV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before modifying damaged parts",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException("This claim is locked by another expert");
}
const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts]
: [];
const selectedParts = body.selectedParts as string[];
if (!(claim as any).damage) (claim as any).damage = {};
(claim as any).damage.selectedParts = selectedParts;
// Keep damaged-parts captures consistent with updated selection.
const damagedPartsMedia = (claim as any).media?.damagedParts;
if (damagedPartsMedia) {
const selectedSet = new Set(selectedParts);
if (damagedPartsMedia instanceof Map) {
for (const key of Array.from(damagedPartsMedia.keys())) {
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
}
} else {
for (const key of Object.keys(damagedPartsMedia)) {
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
}
}
}
if (!Array.isArray((claim as any).history)) (claim as any).history = [];
(claim as any).history.push({
event: "EXPERT_DAMAGED_PARTS_UPDATED",
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: "Damage expert edited selected damaged parts",
metadata: {
previousSelectedParts: previous,
selectedParts,
},
});
await (claim as any).save();
return {
claimRequestId: claim._id.toString(),
selectedParts,
previousSelectedParts: previous,
message: "Damaged parts updated successfully.",
};
}
} }

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' })
@@ -29,13 +32,13 @@ class InPersonVisitV2Dto {
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.DAMAGE_EXPERT)
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) {} constructor(private readonly expertClaimService: ExpertClaimService) { }
@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.sub);
@@ -45,7 +48,7 @@ export class ExpertClaimV2Controller {
@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 status, and damage selections. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation (WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION); in the latter case `evaluation` includes the active expert reply for factors.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async getClaimDetailV2( async getClaimDetailV2(
@@ -88,6 +91,22 @@ 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 (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.",
})
@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 +126,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

@@ -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: "CAR_BODY only. Ignored for THIRD_PARTY.",
description: "Date of accident (for CAR_BODY type only)", 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,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,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,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[];
} }
@@ -44,9 +52,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

@@ -64,6 +64,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 +79,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 +107,12 @@ export class ExpertResend {
@Prop({ type: Types.ObjectId }) @Prop({ type: Types.ObjectId })
requestedByExpertId?: Types.ObjectId; requestedByExpertId?: Types.ObjectId;
/**
* @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

@@ -282,6 +282,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 {

View File

@@ -0,0 +1,553 @@
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 { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto";
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.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 { ExpertCompleteLocationsDto } from "./dto/expert-complete-locations.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 the blame link to the first party (and second party for THIRD_PARTY). SMS delivery is mocked for now; first party opens the link and fills the form via the normal flow. Call after create when creationMethod is LINK.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiResponse({
status: 200,
description: "Link sent (mocked); recipients can open the link to fill the form",
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" },
},
},
},
},
},
})
async sendLinkV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
) {
return this.requestManagementService.sendLinkV2(expert, requestId);
}
@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 locations. After this, call add-locations to submit 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. All nested fields are listed so you can fill or test without guessing property names.",
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",
},
firstPartyDescription: { 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",
},
description: { desc: "توضیح حادثه طرف دوم" },
},
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",
},
firstPartyDescription: {
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: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(
expert,
requestId,
formData,
);
}
@Post("add-locations/:requestId")
@ApiOperation({
summary: "[V2] Submit location(s) for expert-initiated IN_PERSON blame",
description:
"Submit first party (and second party for THIRD_PARTY) location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
type: ExpertCompleteLocationsDto,
examples: {
THIRD_PARTY: {
summary: "THIRD_PARTY locations",
value: {
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
secondPartyLocation: { lat: 35.7001, lon: 51.4102 },
},
},
CAR_BODY: {
summary: "CAR_BODY location",
value: {
firstPartyLocation: { 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: ExpertCompleteLocationsDto,
) {
return this.requestManagementService.expertAddLocationsForBlameV2(
expert,
requestId,
dto,
);
}
@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

@@ -12,7 +12,9 @@ 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 { 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 { 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,6 +37,8 @@ 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: [
@@ -43,6 +47,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
SandHubModule, SandHubModule,
SmsManagerModule, SmsManagerModule,
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

@@ -33,6 +33,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";
@@ -50,7 +51,7 @@ import { RequestManagementService } from "./request-management.service";
export class RequestManagementV2Controller { export class RequestManagementV2Controller {
constructor( constructor(
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
) {} ) { }
@Post() @Post()
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
@@ -64,6 +65,27 @@ 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")
@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 +98,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 +220,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,
@@ -276,10 +332,10 @@ export class RequestManagementV2Controller {
@UploadedFile() sign: Express.Multer.File, @UploadedFile() sign: Express.Multer.File,
) { ) {
// Convert string "true"/"false" to boolean // Convert string "true"/"false" to boolean
const acceptDecision = typeof isAccept === "string" const acceptDecision = typeof isAccept === "string"
? isAccept === "true" ? isAccept === "true"
: Boolean(isAccept); : Boolean(isAccept);
return this.requestManagementService.userSignDecisionV2( return this.requestManagementService.userSignDecisionV2(
requestId, requestId,
user.sub, user.sub,
@@ -331,6 +387,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 +432,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 result = this.mapNewApiResponseToOldFormat(response);
if (result.usgCod !== "8") { let response: any;
throw new Error("خودرو شما شخصی / سواری نمی باشد") if (this.useLiveSandHubApis()) {
response = await this.makeSandHubRequest(requestUrl, requestPayload);
} else {
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,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

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

@@ -1,5 +1,16 @@
import { KavenegarService } from "@fraybabak/kavenegar_nest"; import { KavenegarService } from "@fraybabak/kavenegar_nest";
import { Injectable } from "@nestjs/common"; 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 { export interface SendMessage {
message: string; message: string;
@@ -15,16 +26,84 @@ export interface VerifyLookUpMessage {
@Injectable() @Injectable()
export class SmsManagerService { export class SmsManagerService {
private readonly logger = new Logger(SmsManagerService.name);
constructor(private readonly sender: KavenegarService) {} constructor(private readonly sender: KavenegarService) {}
async sendMessage(data: SendMessage) { async sendMessage(data: SendMessage) {
return await this.sender.Send({ try {
sender: "10008663", const body = await this.sender.Send({
...data, 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) { async verifyLookUp(data: VerifyLookUpMessage) {
return await this.sender.verifyLookup(data); 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

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

@@ -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...
]; ];