forked from Yara724/api
Added refactored version of car-body
This commit is contained in:
2848
package-lock.json
generated
2848
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.2",
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
export enum WorkflowStep {
|
||||
|
||||
CREATED = "CREATED",
|
||||
|
||||
|
||||
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||
|
||||
// ---------- First party ----------
|
||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||
FIRST_VIDEO = "FIRST_VIDEO",
|
||||
|
||||
@@ -71,6 +71,12 @@ export class UserAuthService {
|
||||
});
|
||||
const otp = this.otpCreator.create();
|
||||
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) {
|
||||
await this.smsSender(otp, mobile);
|
||||
/// create otp request
|
||||
@@ -90,9 +96,7 @@ export class UserAuthService {
|
||||
city: "",
|
||||
address: "",
|
||||
state: "",
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
});
|
||||
return new LoginDtoRs(newUser);
|
||||
}
|
||||
@@ -105,9 +109,7 @@ export class UserAuthService {
|
||||
},
|
||||
{
|
||||
otp: hashOtp,
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
},
|
||||
);
|
||||
if (updateTokens) return new LoginDtoRs(userExist);
|
||||
|
||||
@@ -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 { 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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
||||
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
||||
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
||||
@@ -556,11 +557,10 @@ export class ClaimRequestManagementService {
|
||||
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
|
||||
|
||||
if (isCarBody) {
|
||||
// Check if accident is with another object (not a car)
|
||||
// This can be determined by:
|
||||
// 1. carBodyForm.carBodyForm.car === false (explicitly set to false)
|
||||
// 2. OR no secondPartyCarDetail exists (no second party car)
|
||||
const isAccidentWithOtherObject =
|
||||
// Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
|
||||
const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
|
||||
const isAccidentWithOtherObject =
|
||||
(firstPartyCarBody && firstPartyCarBody.object === true) ||
|
||||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
|
||||
!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 || [];
|
||||
if (parties.length < 2) {
|
||||
throw new BadRequestException(
|
||||
"Both parties must be present in the blame request",
|
||||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||||
|
||||
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(
|
||||
(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)
|
||||
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
|
||||
const currentUserParty = parties.find(
|
||||
(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
|
||||
const existingClaim = await this.claimCaseDbService.findOne({
|
||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||
|
||||
@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
|
||||
}
|
||||
|
||||
export class DescriptionDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: "Accident description (required for all types)" })
|
||||
desc: string;
|
||||
|
||||
// CAR_BODY specific fields
|
||||
@ApiPropertyOptional({
|
||||
description: "Date of accident (for CAR_BODY type only)",
|
||||
example: "2025-12-08"
|
||||
@ApiPropertyOptional({
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: "2025-12-08",
|
||||
})
|
||||
accidentDate?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Time of accident (for CAR_BODY type only)",
|
||||
example: "14:30"
|
||||
@ApiPropertyOptional({
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: "14:30",
|
||||
})
|
||||
accidentTime?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@ApiPropertyOptional({
|
||||
enum: WeatherCondition,
|
||||
description: "Weather condition at time of accident (for CAR_BODY type only)",
|
||||
example: WeatherCondition.CLEAR
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: WeatherCondition.CLEAR,
|
||||
})
|
||||
weatherCondition?: WeatherCondition;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@ApiPropertyOptional({
|
||||
enum: RoadCondition,
|
||||
description: "Road condition at time of accident (for CAR_BODY type only)",
|
||||
example: RoadCondition.DRY
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: RoadCondition.DRY,
|
||||
})
|
||||
roadCondition?: RoadCondition;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@ApiPropertyOptional({
|
||||
enum: LightCondition,
|
||||
description: "Light condition at time of accident (for CAR_BODY type only)",
|
||||
example: LightCondition.DAYLIGHT
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: LightCondition.DAYLIGHT,
|
||||
})
|
||||
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 {
|
||||
// @ApiProperty({ type: String })
|
||||
firstPartyId?: Types.ObjectId;
|
||||
|
||||
@@ -50,5 +50,10 @@ export class AccidentInfo {
|
||||
|
||||
@Prop({ type: AccidentClassificationSchema })
|
||||
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);
|
||||
@@ -1,19 +1,27 @@
|
||||
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 { Party, PartySchema } from "./partyRole.enum";
|
||||
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
|
||||
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
||||
import { Workflow, WorkflowSchema } from "./workflow.type";
|
||||
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.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 })
|
||||
export class BlameRequestSnapshot {
|
||||
@Prop({ type: AccidentInfoSchema })
|
||||
accident?: AccidentInfo;
|
||||
|
||||
@Prop({ type: [PartySchema], default: [] })
|
||||
parties?: Party[];
|
||||
}
|
||||
@@ -44,9 +52,6 @@ export class BlameRequest {
|
||||
|
||||
@Prop({ type: WorkflowSchema })
|
||||
workflow?: Workflow;
|
||||
|
||||
@Prop({ type: AccidentInfoSchema })
|
||||
accident?: AccidentInfo;
|
||||
|
||||
@Prop({ type: [PartySchema], default: [] })
|
||||
parties: Party[];
|
||||
@@ -57,12 +62,32 @@ export class BlameRequest {
|
||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||
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).
|
||||
* Source of truth remains the top-level fields of this document.
|
||||
*/
|
||||
@Prop({ type: BlameRequestSnapshotSchema, required: false })
|
||||
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>;
|
||||
|
||||
@@ -70,6 +70,16 @@ export class Insurance {
|
||||
@Prop() financialCeiling?: string;
|
||||
@Prop({ type: [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);
|
||||
|
||||
@@ -87,6 +97,13 @@ export class PartyStatement {
|
||||
|
||||
@Prop({ type: 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);
|
||||
|
||||
@@ -130,6 +147,13 @@ export class Party {
|
||||
@Prop({ type: PersonSchema })
|
||||
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).
|
||||
*/
|
||||
|
||||
244
src/request-management/expert-initiated.v2.controller.ts
Normal file
244
src/request-management/expert-initiated.v2.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { RequestManagementService } from "./request-management.service";
|
||||
import { RequestManagementController } from "./request-management.controller";
|
||||
import { RequestManagementV2Controller } from "./request-management.v2.controller";
|
||||
import { ExpertInitiatedController } from "./expert-initiated.controller";
|
||||
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -63,6 +64,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
|
||||
RequestManagementController,
|
||||
RequestManagementV2Controller,
|
||||
ExpertInitiatedController,
|
||||
ExpertInitiatedV2Controller,
|
||||
],
|
||||
providers: [
|
||||
RequestManagementService,
|
||||
|
||||
@@ -93,6 +93,25 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
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: {
|
||||
stepNumber?: number;
|
||||
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 created = await this.blameRequestDbService.create({
|
||||
@@ -257,7 +336,7 @@ export class RequestManagementService {
|
||||
],
|
||||
workflow: {
|
||||
currentStep: firstStep.stepKey as WorkflowStep,
|
||||
nextStep: nextStepFromManager as WorkflowStep,
|
||||
nextStep,
|
||||
completedSteps: [firstStep.stepKey as WorkflowStep],
|
||||
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(
|
||||
requestId: string,
|
||||
file: Express.Multer.File,
|
||||
@@ -678,6 +824,16 @@ export class RequestManagementService {
|
||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
||||
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
|
||||
await this.advanceWorkflowToNext(req, stepKey);
|
||||
|
||||
@@ -881,11 +1037,37 @@ export class RequestManagementService {
|
||||
if (!party.statement) party.statement = {} as any;
|
||||
party.statement.description = body.desc;
|
||||
|
||||
// Optional: map accident date/time to the request-level accident info (if provided)
|
||||
if (body.accidentDate || body.accidentTime) {
|
||||
if (!req.accident) req.accident = {} as any;
|
||||
if (body.accidentDate) req.accident.date = body.accidentDate as any;
|
||||
if (body.accidentTime) req.accident.time = body.accidentTime as any;
|
||||
// THIRD_PARTY: only desc is accepted; reject CAR_BODY-only fields
|
||||
if (req.type === BlameRequestType.THIRD_PARTY) {
|
||||
const hasCarBodyFields =
|
||||
body.accidentDate != null ||
|
||||
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, we’re 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
|
||||
*/
|
||||
@@ -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).
|
||||
* 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.
|
||||
* 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
|
||||
* This replaces the step-by-step flow for experts filling files on-site
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
CreateBlameRequestDtoV2,
|
||||
DescriptionDto,
|
||||
LocationDto,
|
||||
CarBodyFormDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
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")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: BlameConfessionDtoV2 })
|
||||
@@ -76,6 +90,19 @@ export class RequestManagementV2Controller {
|
||||
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({
|
||||
schema: {
|
||||
type: "object",
|
||||
@@ -185,7 +212,28 @@ export class RequestManagementV2Controller {
|
||||
|
||||
@Post("/add-detail-description/: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)
|
||||
async addDescriptionV2(
|
||||
@Param("requestId") requestId: string,
|
||||
|
||||
@@ -128,9 +128,9 @@ export class CreateWorkflowStepDto {
|
||||
stepKey: WorkflowStep | ClaimWorkflowStep;
|
||||
|
||||
@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',
|
||||
enum: ['THIRD_PARTY', 'CLAIM']
|
||||
enum: ['THIRD_PARTY', 'CAR_BODY', 'CLAIM']
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
|
||||
@@ -1123,6 +1123,43 @@ export class WorkflowStepManagementService {
|
||||
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...
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user