Added refactored version of car-body

This commit is contained in:
SepehrYahyaee
2026-03-14 13:36:05 +03:30
parent 64dfd1ca8a
commit b40270f058
15 changed files with 2786 additions and 1547 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

@@ -71,6 +71,12 @@ export class UserAuthService {
}); });
const otp = this.otpCreator.create(); const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, mobile);
/// create otp request /// create otp request
@@ -90,9 +96,7 @@ export class UserAuthService {
city: "", city: "",
address: "", address: "",
state: "", state: "",
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}); });
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
@@ -105,9 +109,7 @@ export class UserAuthService {
}, },
{ {
otp: hashOtp, otp: hashOtp,
otpExpire: new Date( otpExpire,
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
}, },
); );
if (updateTokens) return new LoginDtoRs(userExist); if (updateTokens) return new LoginDtoRs(userExist);

View File

@@ -49,6 +49,7 @@ import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-reque
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -556,11 +557,10 @@ export class ClaimRequestManagementService {
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY"; const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
if (isCarBody) { if (isCarBody) {
// Check if accident is with another object (not a car) // Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
// This can be determined by: const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
// 1. carBodyForm.carBodyForm.car === false (explicitly set to false) const isAccidentWithOtherObject =
// 2. OR no secondPartyCarDetail exists (no second party car) (firstPartyCarBody && firstPartyCarBody.object === true) ||
const isAccidentWithOtherObject =
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false || claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail; !claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
@@ -2734,44 +2734,68 @@ export class ClaimRequestManagementService {
); );
} }
// 3. Validate expert decision exists
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
// 4. Validate both parties have signed and accepted
const parties = blameRequest.parties || []; const parties = blameRequest.parties || [];
if (parties.length < 2) { const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
throw new BadRequestException(
"Both parties must be present in the blame request", if (isCarBody) {
// CAR_BODY: single party, no expert; first party is the damaged one who creates the claim
if (parties.length < 1) {
throw new BadRequestException("Blame request has no party");
}
const firstParty = parties.find((p) => p.role === "FIRST");
if (!firstParty || String(firstParty.person?.userId) !== currentUserId) {
throw new ForbiddenException(
"Only the first party (damaged) can create a claim from this CAR_BODY blame request",
);
}
} else {
// THIRD_PARTY: require expert decision and both parties signed
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
if (parties.length < 2) {
throw new BadRequestException(
"Both parties must be present in the blame request",
);
}
const allPartiesSigned = parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
); );
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
const guiltyParty = parties.find((p) => {
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
} }
const allPartiesSigned = parties.every( // 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
// 5. Validate current user is the DAMAGED party (NOT the guilty party)
const currentUserParty = parties.find( const currentUserParty = parties.find(
(p) => String(p.person?.userId) === currentUserId, (p) => String(p.person?.userId) === currentUserId,
); );
@@ -2782,24 +2806,6 @@ export class ClaimRequestManagementService {
); );
} }
const currentUserIsGuilty = parties.some(
(p) =>
String(p.person?.userId) === currentUserId &&
String((p as any)._id || p.person?.userId) === guiltyPartyId,
);
// Better check: compare guiltyPartyId with person.userId of each party
const guiltyParty = parties.find((p) => {
// guiltyPartyId could be userId or party identifier
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
// 6. Validate no existing claim for this blame // 6. Validate no existing claim for this blame
const existingClaim = await this.claimCaseDbService.findOne({ const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId), blameRequestId: new Types.ObjectId(blameRequestId),

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

@@ -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,32 @@ 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;
/** 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

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

@@ -0,0 +1,244 @@
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 { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
/**
* 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 })
@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("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; completes the BlameRequest.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
description:
"ExpertCompleteThirdPartyFormDto for THIRD_PARTY or ExpertCompleteCarBodyFormDto for CAR_BODY",
})
@ApiResponse({ status: 200, description: "Blame form completed" })
async completeBlameDataV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() formData: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(
expert,
requestId,
formData,
);
}
@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("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

@@ -35,6 +35,7 @@ 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";
@Module({ @Module({
imports: [ imports: [
@@ -63,6 +64,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
RequestManagementController, RequestManagementController,
RequestManagementV2Controller, RequestManagementV2Controller,
ExpertInitiatedController, ExpertInitiatedController,
ExpertInitiatedV2Controller,
], ],
providers: [ providers: [
RequestManagementService, RequestManagementService,

View File

@@ -93,6 +93,25 @@ export class RequestManagementService {
} }
private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) { private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) {
// CAR_BODY: after first-party description there is no second party or expert; go to COMPLETED
if (
req.type === BlameRequestType.CAR_BODY &&
submittedStepKey === WorkflowStep.FIRST_DESCRIPTION
) {
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey);
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.COMPLETED;
req.workflow.nextStep = undefined;
req.status = CaseStatus.COMPLETED;
this.logger.debug(
"[WORKFLOW] CAR_BODY: advanced to COMPLETED after FIRST_DESCRIPTION",
);
return;
}
const submittedStep = await this.getWorkflowStep({ stepKey: submittedStepKey }); const submittedStep = await this.getWorkflowStep({ stepKey: submittedStepKey });
this.logger.debug( this.logger.debug(
@@ -164,6 +183,60 @@ export class RequestManagementService {
); );
} }
/**
* Ensures CAR_BODY_ACCIDENT_TYPE step exists in DB (e.g. if initialize was not run after adding CAR_BODY flow).
*/
private async ensureCarBodyAccidentTypeStepExists(): Promise<void> {
const exists = await this.workflowStepDbService.exists(
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
);
if (exists) return;
this.logger.log(
"[WORKFLOW] CAR_BODY_ACCIDENT_TYPE step missing; upserting from default definition",
);
await this.workflowStepDbService.upsertByStepKey(
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
{
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" },
} as any,
);
}
private async getWorkflowStep(params: { private async getWorkflowStep(params: {
stepNumber?: number; stepNumber?: number;
stepKey?: WorkflowStep; stepKey?: WorkflowStep;
@@ -235,6 +308,12 @@ export class RequestManagementService {
); );
} }
// CAR_BODY skips confession and goes to accident-type form
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
const publicId = await this.publicIdService.generateRequestPublicId(); const publicId = await this.publicIdService.generateRequestPublicId();
const created = await this.blameRequestDbService.create({ const created = await this.blameRequestDbService.create({
@@ -257,7 +336,7 @@ export class RequestManagementService {
], ],
workflow: { workflow: {
currentStep: firstStep.stepKey as WorkflowStep, currentStep: firstStep.stepKey as WorkflowStep,
nextStep: nextStepFromManager as WorkflowStep, nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep], completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false, locked: false,
}, },
@@ -409,6 +488,73 @@ export class RequestManagementService {
}; };
} }
/**
* V2 CAR_BODY: Submit accident type form (car vs object). Replaces confession step for CAR_BODY.
*/
async carBodyAccidentTypeFormV2(
requestId: string,
body: { car?: boolean; object?: boolean },
user: any,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException("This endpoint is only for CAR_BODY type requests");
}
if (!req.workflow?.nextStep) {
throw new BadRequestException("Request workflow is not initialized");
}
if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) {
throw new BadRequestException(
`Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${req.workflow.nextStep}`,
);
}
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
this.assertPartyOwner(firstParty, user, "Only first party can submit this step");
if (!firstParty.person) firstParty.person = {} as any;
if (!firstParty.person.userId && user?.sub) {
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
const car = !!body?.car;
const object = !!body?.object;
if (!car && !object) {
throw new BadRequestException("Set car or object to true");
}
(firstParty as any).carBodyFirstForm = { car, object };
req.blameStatus = BlameStatus.AGREED;
await this.ensureCarBodyAccidentTypeStepExists();
await this.advanceWorkflowToNext(req, WorkflowStep.CAR_BODY_ACCIDENT_TYPE);
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "CAR_BODY_ACCIDENT_TYPE_SUBMITTED",
actor: {
actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: { car, object },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
blameStatus: req.blameStatus,
};
}
async uploadFirstPartyVideoV2( async uploadFirstPartyVideoV2(
requestId: string, requestId: string,
file: Express.Multer.File, file: Express.Multer.File,
@@ -678,6 +824,16 @@ export class RequestManagementService {
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate; party.insurance.endDate = inquiryMapped?.EndDate;
// CAR_BODY: persist mocked car-body insurance in party's insurance (same place as usual inquiry)
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
String(req._id),
body.plate,
body.nationalCodeOfInsurer,
);
(party.insurance as any).carBodyInsurance = carBodyInfo;
}
// Advance workflow // Advance workflow
await this.advanceWorkflowToNext(req, stepKey); await this.advanceWorkflowToNext(req, stepKey);
@@ -881,11 +1037,37 @@ export class RequestManagementService {
if (!party.statement) party.statement = {} as any; if (!party.statement) party.statement = {} as any;
party.statement.description = body.desc; party.statement.description = body.desc;
// Optional: map accident date/time to the request-level accident info (if provided) // THIRD_PARTY: only desc is accepted; reject CAR_BODY-only fields
if (body.accidentDate || body.accidentTime) { if (req.type === BlameRequestType.THIRD_PARTY) {
if (!req.accident) req.accident = {} as any; const hasCarBodyFields =
if (body.accidentDate) req.accident.date = body.accidentDate as any; body.accidentDate != null ||
if (body.accidentTime) req.accident.time = body.accidentTime as any; body.accidentTime != null ||
body.weatherCondition != null ||
body.roadCondition != null ||
body.lightCondition != null;
if (hasCarBodyFields) {
throw new BadRequestException(
"THIRD_PARTY description step accepts only the desc field. Omit accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.",
);
}
} else if (req.type === BlameRequestType.CAR_BODY) {
// CAR_BODY: require desc + accident date/time and conditions
if (
body.accidentDate == null ||
body.accidentTime == null ||
body.weatherCondition == null ||
body.roadCondition == null ||
body.lightCondition == null
) {
throw new BadRequestException(
"CAR_BODY description step requires desc, accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.",
);
}
party.statement.accidentDate = body.accidentDate as any;
party.statement.accidentTime = body.accidentTime;
(party.statement as any).weatherCondition = body.weatherCondition;
(party.statement as any).roadCondition = body.roadCondition;
(party.statement as any).lightCondition = body.lightCondition;
} }
// If second party finished description, were ready for expert flow. // If second party finished description, were ready for expert flow.
@@ -2988,6 +3170,22 @@ export class RequestManagementService {
} }
} }
/**
* Verify the current user (field expert) can access this BlameRequest (v2).
*/
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
if (!req?.expertInitiated || !req?.initiatedByFieldExpertId) {
throw new ForbiddenException(
"This file is not expert-initiated or has no initiating expert",
);
}
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
}
/** /**
* Helper method to add history event to a request * Helper method to add history event to a request
*/ */
@@ -3120,6 +3318,151 @@ export class RequestManagementService {
}; };
} }
/**
* V2: Field expert creates an expert-initiated blame file (BlameRequest with workflow).
* LINK: creates request with parties identified by phone; expert sends link to user(s) who then fill via normal v2 flow.
* IN_PERSON: creates minimal request; expert fills everything via complete-blame-data v2.
*/
async createExpertInitiatedBlameV2(
expert: any,
dto: CreateExpertInitiatedFileDto,
): Promise<{ requestId: string; publicId: string; linkUrl?: string }> {
const expertId = new Types.ObjectId(expert.sub);
const type =
dto.type === "CAR_BODY"
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
if (dto.creationMethod === CreationMethod.LINK) {
if (!dto.firstPartyPhoneNumber) {
throw new BadRequestException(
"First party phone number is required for LINK method",
);
}
if (
type === BlameRequestType.THIRD_PARTY &&
!dto.secondPartyPhoneNumber
) {
throw new BadRequestException(
"Second party phone number is required for LINK method with THIRD_PARTY type",
);
}
if (
type === BlameRequestType.THIRD_PARTY &&
dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber
) {
throw new BadRequestException(
"First and second party phone numbers must be different",
);
}
}
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
if (!nextStep) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
const publicId = await this.publicIdService.generateRequestPublicId();
const parties: any[] = [];
if (dto.creationMethod === CreationMethod.LINK) {
const firstUserId = await this.getOrCreateUserByPhoneNumber(
dto.firstPartyPhoneNumber,
);
parties.push({
role: PartyRole.FIRST,
person: {
userId: firstUserId,
phoneNumber: dto.firstPartyPhoneNumber,
},
});
if (
type === BlameRequestType.THIRD_PARTY &&
dto.secondPartyPhoneNumber
) {
const secondUserId = await this.getOrCreateUserByPhoneNumber(
dto.secondPartyPhoneNumber,
);
parties.push({
role: PartyRole.SECOND,
person: {
userId: secondUserId,
phoneNumber: dto.secondPartyPhoneNumber,
},
});
}
} else {
parties.push({
role: PartyRole.FIRST,
person: {},
});
}
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties,
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
expertInitiated: true,
initiatedByFieldExpertId: expertId,
creationMethod: dto.creationMethod,
filledBy:
dto.creationMethod === CreationMethod.IN_PERSON
? FilledBy.EXPERT
: FilledBy.CUSTOMER,
});
const requestId = String((created as any)._id);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_FIELD_EXPERT",
actor: {
actorId: expertId,
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { creationMethod: dto.creationMethod, type: dto.type },
});
await (created as any).save();
const result: {
requestId: string;
publicId: string;
linkUrl?: string;
} = {
requestId,
publicId: (created as any).publicId,
};
if (dto.creationMethod === CreationMethod.LINK) {
const baseUrl =
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
result.linkUrl = baseUrl
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
: undefined;
}
return result;
}
/** /**
* List all expert-initiated blame files for the current field expert (their panel). * List all expert-initiated blame files for the current field expert (their panel).
* Only files where initiatedBy === current user are returned. * Only files where initiatedBy === current user are returned.
@@ -3143,6 +3486,72 @@ export class RequestManagementService {
})); }));
} }
/**
* V2: List expert-initiated blame files (BlameRequest) for the current field expert.
* Only files where initiatedByFieldExpertId === current expert are returned.
*/
async getMyExpertInitiatedFilesV2(expert: any): Promise<any[]> {
const expertId = new Types.ObjectId(expert.sub);
const files = await this.blameRequestDbService.find({
expertInitiated: true,
initiatedByFieldExpertId: expertId,
});
return (files || []).map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
creationMethod: f.creationMethod,
filledBy: f.filledBy,
status: f.status,
blameStatus: f.blameStatus,
workflow: f.workflow,
partiesCount: Array.isArray(f.parties) ? f.parties.length : 0,
createdAt: f.createdAt,
}));
}
/**
* V2: Get one blame request by id. Access allowed if current user is a party (by userId or phone)
* or the initiating field expert. For expert-initiated LINK, party access by phone is sufficient.
*/
async getBlameRequestV2(requestId: string, user: any): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
const isFieldExpertOwner =
req.expertInitiated &&
req.initiatedByFieldExpertId &&
String(req.initiatedByFieldExpertId) === String(user?.sub);
const isParty = Array.isArray(req.parties) && req.parties.some((p: any) => {
const pid = p?.person?.userId ? String(p.person.userId) : null;
const phone = p?.person?.phoneNumber;
return (pid && pid === String(user?.sub)) || (phone && phone === user?.username);
});
if (!isFieldExpertOwner && !isParty) {
throw new ForbiddenException("You do not have access to this request");
}
// Optionally bind userId to party when user opens via LINK (phone match, no userId yet)
if (!isFieldExpertOwner && user?.sub && Types.ObjectId.isValid(user.sub)) {
let updated = false;
for (const p of req.parties || []) {
if (
p?.person?.phoneNumber === user?.username &&
!p.person?.userId
) {
p.person = p.person || {};
(p.person as any).userId = new Types.ObjectId(user.sub);
updated = true;
}
}
if (updated) {
await (req as any).save();
}
}
return req;
}
/** /**
* Single endpoint handler: complete all blame-needed data. * Single endpoint handler: complete all blame-needed data.
* Routes to THIRD_PARTY or CAR_BODY completion based on request type. * Routes to THIRD_PARTY or CAR_BODY completion based on request type.
@@ -3174,6 +3583,459 @@ export class RequestManagementService {
); );
} }
/**
* V2: Expert completes blame data for IN_PERSON BlameRequest.
* Delegates to expertCompleteThirdPartyFormV2 or expertCompleteCarBodyFormV2.
*/
async expertCompleteBlameDataV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON BlameRequest files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (req.type === BlameRequestType.THIRD_PARTY) {
return this.expertCompleteThirdPartyFormV2(expert, requestId, formData);
}
if (req.type === BlameRequestType.CAR_BODY) {
return this.expertCompleteCarBodyFormV2(expert, requestId, formData);
}
throw new BadRequestException(
"Unknown file type. Must be THIRD_PARTY or CAR_BODY.",
);
}
/**
* V2: Expert completes IN_PERSON CAR_BODY BlameRequest in one go.
*/
async expertCompleteCarBodyFormV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException("Only expert-initiated IN_PERSON CAR_BODY files.");
}
if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException("File type is not CAR_BODY.");
}
this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.carBodyForm) {
throw new BadRequestException("carBodyForm is required.");
}
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
const firstPartyPlate = formData.firstPartyPlate;
let sandHubReport: any;
try {
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: firstPartyPlate.plate,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
});
sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
} catch (e) {
this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e);
throw new InternalServerErrorException("Failed to process plate information.");
}
const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
const client = companyCode
? await this.clientService.findClientWithCompanyCode(+companyCode)
: await this.clientService.findOne({ clientName });
if (!client) {
throw new NotFoundException(`Client not found for company: ${clientName}`);
}
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
requestId,
firstPartyPlate.plate,
firstPartyPlate.nationalCodeOfInsurer,
);
const firstParty: any = {
role: PartyRole.FIRST,
person: {
userId: firstPartyUserId,
phoneNumber: formData.firstPartyPhoneNumber,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
insurerLicense: firstPartyPlate.insurerLicense,
driverLicense: firstPartyPlate.driverLicense,
driverIsInsurer: firstPartyPlate.driverIsInsurer,
isNewCar: firstPartyPlate.isNewCar,
userNoCertificate: firstPartyPlate.userNoCertificate,
insurerBirthday: firstPartyPlate.insurerBirthday,
driverBirthday: firstPartyPlate.driverBirthday,
},
vehicle: {
plateId: firstPartyPlate.plate,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNew: firstPartyPlate.isNewCar,
inquiry: sandHubReport,
},
insurance: {
policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber,
company: clientName,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
carBodyInsurance: {
policyNumber: carBodyInfo.policyNumber,
startDate: carBodyInfo.startDate,
endDate: carBodyInfo.endDate,
insurerCompany: carBodyInfo.insurerCompany,
coverages: carBodyInfo.coverages,
},
},
statement: {
description: formData.firstPartyDescription?.desc,
accidentDate: formData.firstPartyDescription?.accidentDate,
accidentTime: formData.firstPartyDescription?.accidentTime,
weatherCondition: formData.firstPartyDescription?.weatherCondition,
roadCondition: formData.firstPartyDescription?.roadCondition,
lightCondition: formData.firstPartyDescription?.lightCondition,
},
location: formData.firstPartyLocation,
carBodyFirstForm: {
car: !!formData.carBodyForm.car,
object: !!formData.carBodyForm.object,
},
evidence: req.parties?.[0]?.evidence ? { ...req.parties[0].evidence } : {},
};
req.parties = [firstParty];
req.workflow = {
currentStep: WorkflowStep.COMPLETED,
nextStep: undefined,
completedSteps: [
WorkflowStep.CREATED,
WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
WorkflowStep.FIRST_VIDEO,
WorkflowStep.FIRST_INITIAL_FORM,
WorkflowStep.FIRST_LOCATION,
WorkflowStep.FIRST_VOICE,
WorkflowStep.FIRST_DESCRIPTION,
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.COMPLETED;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_COMPLETED_CAR_BODY_FORM_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
/**
* V2: Expert completes IN_PERSON THIRD_PARTY BlameRequest in one go.
*/
async expertCompleteThirdPartyFormV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException("Only expert-initiated IN_PERSON THIRD_PARTY files.");
}
if (req.type !== BlameRequestType.THIRD_PARTY) {
throw new BadRequestException("File type is not THIRD_PARTY.");
}
this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required.");
}
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
const secondPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.secondParty.phoneNumber,
);
const buildPartyFromForm = async (
phoneNumber: string,
userId: Types.ObjectId,
initialForm: any,
plateDto: any,
locationDto: any,
desc: string,
role: PartyRole,
) => {
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: plateDto.plate,
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
});
const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any;
const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
const client = companyCode
? await this.clientService.findClientWithCompanyCode(+companyCode)
: await this.clientService.findOne({ clientName });
if (!client) {
throw new NotFoundException(`Client not found for company: ${clientName}`);
}
return {
role,
person: {
userId,
phoneNumber,
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
insurerLicense: plateDto.insurerLicense,
driverLicense: plateDto.driverLicense,
driverIsInsurer: plateDto.driverIsInsurer,
isNewCar: plateDto.isNewCar,
userNoCertificate: plateDto.userNoCertificate,
insurerBirthday: plateDto.insurerBirthday,
driverBirthday: plateDto.driverBirthday,
},
vehicle: {
plateId: plateDto.plate,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNew: plateDto.isNewCar,
inquiry: sandHubReport,
},
insurance: {
policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber,
company: clientName,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
},
statement: { description: desc },
location: locationDto,
evidence: {},
};
};
const firstParty = await buildPartyFromForm(
formData.firstPartyPhoneNumber,
firstPartyUserId,
formData.firstPartyInitialForm,
formData.firstPartyPlate,
formData.firstPartyLocation,
formData.firstPartyDescription?.desc || "",
PartyRole.FIRST,
);
const secondParty = await buildPartyFromForm(
formData.secondParty.phoneNumber,
secondPartyUserId,
formData.secondParty.initialForm,
formData.secondParty.plate,
formData.secondParty.location,
formData.secondParty.description?.desc || "",
PartyRole.SECOND,
);
req.parties = [firstParty, secondParty];
req.workflow = {
currentStep: WorkflowStep.COMPLETED,
nextStep: undefined,
completedSteps: [
WorkflowStep.CREATED,
WorkflowStep.FIRST_BLAME_CONFESSION,
WorkflowStep.FIRST_VIDEO,
WorkflowStep.FIRST_INITIAL_FORM,
WorkflowStep.FIRST_LOCATION,
WorkflowStep.FIRST_VOICE,
WorkflowStep.FIRST_DESCRIPTION,
WorkflowStep.SECOND_INITIAL_FORM,
WorkflowStep.SECOND_LOCATION,
WorkflowStep.SECOND_VOICE,
WorkflowStep.SECOND_DESCRIPTION,
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.COMPLETED;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_COMPLETED_THIRD_PARTY_FORM_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { guiltyPartyPhoneNumber: formData.guiltyPartyPhoneNumber },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
/**
* V2: Expert uploads video for expert-initiated BlameRequest (first party).
*/
async expertUploadVideoForBlameV2(
expert: any,
requestId: string,
file: Express.Multer.File,
): Promise<any> {
if (!file) throw new BadRequestException("Video file is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
throw new BadRequestException("This endpoint is only for expert-initiated files.");
}
this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Video already uploaded for this file");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
await (req as any).save();
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_UPLOADED_VIDEO_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { videoId: firstParty.evidence.videoId },
} as any);
await (req as any).save();
return { requestId: req._id, videoId: firstParty.evidence.videoId };
}
/**
* V2: Expert uploads voice for expert-initiated BlameRequest (first party).
*/
async expertUploadVoiceForBlameV2(
expert: any,
requestId: string,
voiceFile: Express.Multer.File,
): Promise<any> {
if (!voiceFile) throw new BadRequestException("Voice file is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
throw new BadRequestException("This endpoint is only for expert-initiated files.");
}
this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
const voiceDocument = await this.blameVoiceDbService.create({
fileName: voiceFile.filename,
path: voiceFile.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
if (!Array.isArray(firstParty.evidence.voices)) firstParty.evidence.voices = [];
firstParty.evidence.voices.push(String((voiceDocument as any)._id));
await (req as any).save();
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_UPLOADED_VOICE_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {},
} as any);
await (req as any).save();
return { requestId: req._id };
}
/**
* V2: Expert adds accident fields to expert-initiated BlameRequest.
*/
async expertAddAccidentFieldsForBlameV2(
expert: any,
requestId: string,
fields: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
throw new BadRequestException("This endpoint is only for expert-initiated files.");
}
this.verifyExpertAccessForBlameV2(req, expert);
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
(req.expert as any).decision = {
...(req.expert as any).decision,
fields: {
accidentWay: fields.accidentWay,
accidentReason: fields.accidentReason,
accidentType: fields.accidentType,
},
};
await (req as any).save();
return { requestId: req._id };
}
/** /**
* Expert completes all information for IN_PERSON THIRD_PARTY file at once * Expert completes all information for IN_PERSON THIRD_PARTY file at once
* This replaces the step-by-step flow for experts filling files on-site * This replaces the step-by-step flow for experts filling files on-site

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";
@@ -64,6 +65,19 @@ export class RequestManagementV2Controller {
); );
} }
/**
* 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 +90,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 +212,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,

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

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