forked from Yara724/api
Added refactored version of car-body
This commit is contained in:
2846
package-lock.json
generated
2846
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export enum WorkflowStep {
|
|||||||
|
|
||||||
CREATED = "CREATED",
|
CREATED = "CREATED",
|
||||||
|
|
||||||
|
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||||
|
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||||
|
|
||||||
// ---------- First party ----------
|
// ---------- First party ----------
|
||||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||||
FIRST_VIDEO = "FIRST_VIDEO",
|
FIRST_VIDEO = "FIRST_VIDEO",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
|
||||||
// 2. OR no secondPartyCarDetail exists (no second party car)
|
|
||||||
const isAccidentWithOtherObject =
|
const isAccidentWithOtherObject =
|
||||||
|
(firstPartyCarBody && firstPartyCarBody.object === true) ||
|
||||||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
|
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
|
||||||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
|
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
|
||||||
|
|
||||||
@@ -2734,7 +2734,22 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Validate expert decision exists
|
const parties = blameRequest.parties || [];
|
||||||
|
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) {
|
if (!blameRequest.expert?.decision) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Cannot create claim until expert has made a decision",
|
"Cannot create claim until expert has made a decision",
|
||||||
@@ -2743,8 +2758,6 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
|
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
|
||||||
|
|
||||||
// 4. Validate both parties have signed and accepted
|
|
||||||
const parties = blameRequest.parties || [];
|
|
||||||
if (parties.length < 2) {
|
if (parties.length < 2) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Both parties must be present in the blame request",
|
"Both parties must be present in the blame request",
|
||||||
@@ -2771,7 +2784,18 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Validate current user is the DAMAGED party (NOT the guilty party)
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
|
||||||
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),
|
||||||
|
|||||||
@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DescriptionDto {
|
export class DescriptionDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: "Accident description (required for all types)" })
|
||||||
desc: string;
|
desc: string;
|
||||||
|
|
||||||
// CAR_BODY specific fields
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Date of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: "2025-12-08"
|
example: "2025-12-08",
|
||||||
})
|
})
|
||||||
accidentDate?: Date;
|
accidentDate?: Date;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: "14:30"
|
example: "14:30",
|
||||||
})
|
})
|
||||||
accidentTime?: string;
|
accidentTime?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: WeatherCondition,
|
enum: WeatherCondition,
|
||||||
description: "Weather condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: WeatherCondition.CLEAR
|
example: WeatherCondition.CLEAR,
|
||||||
})
|
})
|
||||||
weatherCondition?: WeatherCondition;
|
weatherCondition?: WeatherCondition;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: RoadCondition,
|
enum: RoadCondition,
|
||||||
description: "Road condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: RoadCondition.DRY
|
example: RoadCondition.DRY,
|
||||||
})
|
})
|
||||||
roadCondition?: RoadCondition;
|
roadCondition?: RoadCondition;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: LightCondition,
|
enum: LightCondition,
|
||||||
description: "Light condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: LightCondition.DAYLIGHT
|
example: LightCondition.DAYLIGHT,
|
||||||
})
|
})
|
||||||
lightCondition?: LightCondition;
|
lightCondition?: LightCondition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THIRD_PARTY description step: only desc is accepted.
|
||||||
|
*/
|
||||||
|
export class ThirdPartyDescriptionDto {
|
||||||
|
@ApiProperty({ description: "Accident description" })
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAR_BODY description step: desc + accident date/time and conditions required.
|
||||||
|
*/
|
||||||
|
export class CarBodyDescriptionDto {
|
||||||
|
@ApiProperty({ description: "Accident description" })
|
||||||
|
desc: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Date of accident", example: "2025-12-08" })
|
||||||
|
accidentDate: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Time of accident", example: "14:30" })
|
||||||
|
accidentTime: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: WeatherCondition, example: WeatherCondition.CLEAR })
|
||||||
|
weatherCondition: WeatherCondition;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: RoadCondition, example: RoadCondition.DRY })
|
||||||
|
roadCondition: RoadCondition;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: LightCondition, example: LightCondition.DAYLIGHT })
|
||||||
|
lightCondition: LightCondition;
|
||||||
|
}
|
||||||
|
|
||||||
export class FirstPartyDetail {
|
export class FirstPartyDetail {
|
||||||
// @ApiProperty({ type: String })
|
// @ApiProperty({ type: String })
|
||||||
firstPartyId?: Types.ObjectId;
|
firstPartyId?: Types.ObjectId;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { HydratedDocument } from "mongoose";
|
import { HydratedDocument, Types } from "mongoose";
|
||||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { Party, PartySchema } from "./partyRole.enum";
|
import { Party, PartySchema } from "./partyRole.enum";
|
||||||
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
|
|
||||||
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
||||||
import { Workflow, WorkflowSchema } from "./workflow.type";
|
import { Workflow, WorkflowSchema } from "./workflow.type";
|
||||||
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||||
|
import { CreationMethod, FilledBy } from "./request-management.schema";
|
||||||
|
|
||||||
|
/** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class CarBodyInsuranceDetail {
|
||||||
|
@Prop() policyNumber?: string;
|
||||||
|
@Prop() startDate?: string;
|
||||||
|
@Prop() endDate?: string;
|
||||||
|
@Prop() insurerCompany?: string;
|
||||||
|
@Prop({ type: [String] }) coverages?: string[];
|
||||||
|
}
|
||||||
|
export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail);
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class BlameRequestSnapshot {
|
export class BlameRequestSnapshot {
|
||||||
@Prop({ type: AccidentInfoSchema })
|
|
||||||
accident?: AccidentInfo;
|
|
||||||
|
|
||||||
@Prop({ type: [PartySchema], default: [] })
|
@Prop({ type: [PartySchema], default: [] })
|
||||||
parties?: Party[];
|
parties?: Party[];
|
||||||
}
|
}
|
||||||
@@ -45,9 +53,6 @@ export class BlameRequest {
|
|||||||
@Prop({ type: WorkflowSchema })
|
@Prop({ type: WorkflowSchema })
|
||||||
workflow?: Workflow;
|
workflow?: Workflow;
|
||||||
|
|
||||||
@Prop({ type: AccidentInfoSchema })
|
|
||||||
accident?: AccidentInfo;
|
|
||||||
|
|
||||||
@Prop({ type: [PartySchema], default: [] })
|
@Prop({ type: [PartySchema], default: [] })
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
|
|
||||||
@@ -57,12 +62,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>;
|
||||||
|
|||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@@ -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, we’re ready for expert flow.
|
// 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
|
* 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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...
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user