1
0
forked from Yara724/api

Added Field Expert flows, and deactivated inquiry

This commit is contained in:
2026-03-16 15:39:39 +03:30
parent b40270f058
commit fc599acf4a
10 changed files with 1067 additions and 88 deletions

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,11 @@ import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-par
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
/**
* V2 expert-initiated blame API: uses BlameRequest (workflow) model.
@@ -80,7 +84,51 @@ export class ExpertInitiatedV2Controller {
description:
"Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.",
})
@ApiBody({ type: CreateExpertInitiatedFileDto })
@ApiBody({
type: CreateExpertInitiatedFileDto,
examples: {
thirdPartyInPerson: {
summary: "Third-party, IN_PERSON (both parties)",
description:
"Expert fills form on-site for both parties.",
value: {
type: "THIRD_PARTY",
creationMethod: "IN_PERSON",
firstPartyPhoneNumber: "09123456789",
secondPartyPhoneNumber: "09187654321",
},
},
thirdPartyLink: {
summary: "Third-party, LINK",
description:
"Expert sends link to first and second party by phone.",
value: {
type: "THIRD_PARTY",
creationMethod: "LINK",
firstPartyPhoneNumber: "09123456789",
},
},
carBodyInPerson: {
summary: "Car-body, IN_PERSON",
description: "Expert fills form on-site for single party (car body).",
value: {
type: "CAR_BODY",
creationMethod: "IN_PERSON",
firstPartyPhoneNumber: "09123456789",
},
},
carBodyLink: {
summary: "Car-body, LINK",
description:
"Expert sends link to party by phone. Only first party phone needed.",
value: {
type: "CAR_BODY",
creationMethod: "LINK",
firstPartyPhoneNumber: "09123456789",
},
},
},
})
@ApiResponse({
status: 201,
description: "File created",
@@ -103,18 +151,173 @@ export class ExpertInitiatedV2Controller {
);
}
@Post("send-link/:requestId")
@ApiOperation({
summary: "[V2] Send blame link to party/parties (LINK)",
description:
"For expert-initiated LINK files only. Sends the blame link to the first party (and second party for THIRD_PARTY). SMS delivery is mocked for now; first party opens the link and fills the form via the normal flow. Call after create when creationMethod is LINK.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiResponse({
status: 200,
description: "Link sent (mocked); recipients can open the link to fill the form",
schema: {
type: "object",
properties: {
sent: { type: "boolean" },
linkUrl: { type: "string" },
sentTo: {
type: "array",
items: {
type: "object",
properties: {
role: { type: "string", enum: ["FIRST", "SECOND"] },
phoneNumber: { type: "string" },
},
},
},
},
},
})
async sendLinkV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
) {
return this.requestManagementService.sendLinkV2(expert, requestId);
}
@Post("send-party-otps/:requestId")
@ApiOperation({
summary: "[V2] Send OTP to party/parties (IN_PERSON)",
description:
"Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: SendPartyOtpsDto })
@ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" })
async sendPartyOtpsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpsDto,
) {
return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
}
@Post("verify-party-otps/:requestId")
@ApiOperation({
summary: "[V2] Verify party OTPs (IN_PERSON)",
description:
"After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: VerifyPartyOtpsDto })
@ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" })
async verifyPartyOtpsV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpsDto,
) {
return this.requestManagementService.verifyPartyOtpsV2(expert, requestId, dto);
}
@Post("complete-blame-data/:requestId")
@ApiOperation({
summary: "[V2] Submit all blame data (IN_PERSON)",
description:
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form; completes the BlameRequest.",
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form. After this, status becomes WAITING_FOR_SIGNATURES; use upload-party-signature to collect party signatures.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
description:
"ExpertCompleteThirdPartyFormDto for THIRD_PARTY or ExpertCompleteCarBodyFormDto for CAR_BODY",
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. All nested fields are listed so you can fill or test without guessing property names.",
examples: {
THIRD_PARTY: {
summary: "THIRD_PARTY",
description: "Use when the blame file type is THIRD_PARTY",
value: {
firstPartyPhoneNumber: "09123456789",
firstPartyInitialForm: {
expertOpinion: false,
imDamaged: false,
imGuilty: true,
},
firstPartyPlate: {
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 13770624,
driverBirthday: "1370-01-01",
},
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
firstPartyDescription: { desc: "توضیح حادثه طرف اول" },
secondParty: {
phoneNumber: "09187654321",
initialForm: {
expertOpinion: false,
imDamaged: true,
imGuilty: false,
},
plate: {
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 13700720,
driverBirthday: "1370-01-01",
},
location: { lat: 35.6892, lon: 51.389 },
description: { desc: "توضیح حادثه طرف دوم" },
},
guiltyPartyPhoneNumber: "09123456789",
},
},
CAR_BODY: {
summary: "CAR_BODY",
description: "Use when the blame file type is CAR_BODY",
value: {
firstPartyPhoneNumber: "09123456789",
firstPartyInitialForm: {
expertOpinion: false,
imDamaged: false,
imGuilty: true,
},
carBodyForm: { car: true, object: false },
firstPartyPlate: {
plateId: "",
nationalCodeOfInsurer: "",
nationalCodeOfDriver: "",
insurerLicense: "",
driverLicense: "",
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
driverIsInsurer: true,
isNewCar: false,
userNoCertificate: false,
insurerBirthday: 1370,
driverBirthday: "1370-01-01",
},
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
firstPartyDescription: {
desc: "توضیح حادثه",
accidentDate: "2025-01-15",
accidentTime: "14:30",
weatherCondition: "صاف",
roadCondition: "خشک",
lightCondition: "روز",
},
},
},
},
schema: { type: "object" },
})
@ApiResponse({ status: 200, description: "Blame form completed" })
@ApiResponse({ status: 200, description: "Blame form completed; next: upload party signature(s)" })
async completeBlameDataV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@@ -223,6 +426,76 @@ export class ExpertInitiatedV2Controller {
);
}
@Post("upload-party-signature/:requestId")
@ApiOperation({
summary: "[V2] Expert uploads party signature (IN_PERSON)",
description:
"For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
required: ["partyRole", "sign"],
properties: {
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
isAccept: { type: "boolean", default: true },
sign: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `expert-party-${unique}${ex}`);
},
}),
}),
)
@ApiResponse({ status: 200, description: "Signature recorded" })
async uploadPartySignatureV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@UploadedFile() sign?: Express.Multer.File,
) {
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
? body.partyRole
: ("FIRST" as const);
const isAccept = body.isAccept === false || body.isAccept === "false" ? false : true;
return this.requestManagementService.expertUploadPartySignatureV2(
expert,
requestId,
partyRole as PartyRole,
isAccept,
sign!,
);
}
@Post("create-claim-from-blame/:blameRequestId")
@ApiOperation({
summary: "[V2] Create claim from expert-initiated IN_PERSON blame",
description:
"Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.",
})
@ApiParam({ name: "blameRequestId", description: "Completed blame request ID" })
@ApiResponse({ status: 201, description: "Claim created" })
async createClaimFromBlame(
@CurrentUser() expert: any,
@Param("blameRequestId") blameRequestId: string,
) {
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
blameRequestId,
expert,
);
}
@Post("complete-claim-data/:claimRequestId")
@ApiOperation({
summary: "Submit claim-needed data (expert-initiated IN_PERSON)",

View File

@@ -12,7 +12,9 @@ import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { HashModule } from "src/utils/hash/hash.module";
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
import { AuthModule } from "src/auth/auth.module";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
import { UserSignDbService } from "./entities/db-service/sign.db.service";
@@ -44,6 +46,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
SandHubModule,
SmsManagerModule,
PublicIdModule,
HashModule,
WorkflowStepManagementModule,
PlatesModule,
MulterModule.register({
@@ -59,6 +62,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
]),
forwardRef(() => ClaimRequestManagementModule),
CronModule,
AuthModule,
],
controllers: [
RequestManagementController,

View File

@@ -65,6 +65,8 @@ import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { UploadContext } from "./entities/schema/blame-voice.schema";
import { HashService } from "src/utils/hash/hash.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable()
export class RequestManagementService {
@@ -78,6 +80,14 @@ export class RequestManagementService {
);
}
/** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */
private plateToPlateIdString(plate: any): string {
if (plate == null) return "";
if (typeof plate === "string") return plate;
const p = plate as { ir?: number; leftDigits?: number; centerAlphabet?: string; centerDigits?: number };
return [p.ir, p.leftDigits, p.centerAlphabet, p.centerDigits].filter((x) => x != null).join("-");
}
private getPartyIndex(req: any, role: PartyRole): number {
return Array.isArray(req.parties)
? req.parties.findIndex((p) => p?.role === role)
@@ -292,6 +302,8 @@ export class RequestManagementService {
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly publicIdService: PublicIdService,
private readonly workflowStepDbService: WorkflowStepDbService,
private readonly hashService: HashService,
private readonly userAuthService: UserAuthService,
) {}
async createRequestV2(user: any, type: BlameRequestType) {
@@ -738,12 +750,12 @@ export class RequestManagementService {
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry({
plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
});
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
// const inquiry = await this.sandHubService.getTejaratBlockInquiry({
// plate: body.plate,
// nationalCodeOfInsurer: body.nationalCodeOfInsurer,
// });
// inquiryRaw = inquiry.raw;
// inquiryMapped = inquiry.mapped;
this.logger.log(
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
);
@@ -808,7 +820,10 @@ export class RequestManagementService {
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.isNew = body.isNewCar;
party.vehicle.plateId = body.plateId;
party.vehicle.plateId =
typeof body.plateId === "string"
? body.plateId
: this.plateToPlateIdString(body.plate);
party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = {
@@ -1140,9 +1155,53 @@ export class RequestManagementService {
);
}
// Check if second party already exists
// Check if second party already exists (e.g. expert-initiated LINK: expert already added both parties)
const secondPartyIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondPartyIdx !== -1) {
if (
req.expertInitiated &&
req.creationMethod === CreationMethod.LINK
) {
// Just advance workflow; second party already in parties. Optionally resend SMS.
const secondPartyPhone =
(req.parties[secondPartyIdx]?.person as any)?.phoneNumber || phoneNumber;
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
await this.advanceWorkflowToNext(req, stepKey);
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "SECOND_PARTY_INVITED",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: { secondPartyPhone, expertInitiatedLink: true },
} as any);
await (req as any).save();
const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
try {
await this.smsManagerService.verifyLookUp({
token: url,
template: "yara724-invite-link",
receptor: secondPartyPhone,
});
this.logger.log(
`[SMS] Invitation resent to ${secondPartyPhone} for expert-initiated request ${req.publicId}`,
);
} catch (err) {
this.logger.error(
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${err?.message || err}`,
);
}
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
invitationUrl: url,
};
}
throw new ConflictException("Second party already added to this request");
}
@@ -1602,47 +1661,48 @@ export class RequestManagementService {
}
}
// TODO: Activate
// --- Proceed with existing logic if the check passes ---
// 1) Main third-party/block inquiry (existing behavior)
const sanHubResponse = await this.sandHubService.getSandHubResponse({
plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
});
// const sanHubResponse = await this.sandHubService.getSandHubResponse({
// plate: body.plate,
// nationalCodeOfInsurer: body.nationalCodeOfInsurer,
// });
if (sanHubResponse.Error) {
throw new HttpException(
sanHubResponse.Error.Message,
HttpStatus.BAD_REQUEST,
);
}
// if (sanHubResponse.Error) {
// throw new HttpException(
// sanHubResponse.Error.Message,
// HttpStatus.BAD_REQUEST,
// );
// }
// 2) Personal inquiry (new shared SandHub API)
// This is a best-effort call; if it fails we log and continue.
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
body.nationalCodeOfInsurer,
body.insurerBirthday,
);
this.logger.log(
`[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify(
personalInquiry,
)}`,
);
// NOTE: We are not persisting this data yet; once the exact fields
// are finalized, we can map and store them on the request document.
} catch (err) {
this.logger.error(
`[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`,
);
}
// try {
// const personalInquiry = await this.sandHubService.getPersonalInquiry(
// body.nationalCodeOfInsurer,
// body.insurerBirthday,
// );
// this.logger.log(
// `[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify(
// personalInquiry,
// )}`,
// );
// // NOTE: We are not persisting this data yet; once the exact fields
// // are finalized, we can map and store them on the request document.
// } catch (err) {
// this.logger.error(
// `[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`,
// );
// }
return await this.addPlate(
sanHubResponse,
request,
user,
body,
isFirstParty ? "firstParty" : "secondParty",
);
// return await this.addPlate(
// sanHubResponse,
// request,
// user,
// body,
// isFirstParty ? "firstParty" : "secondParty",
// );
}
async carBodyFormStep(
@@ -3463,6 +3523,212 @@ export class RequestManagementService {
return result;
}
/**
* V2: Send blame link to first party (and second party for THIRD_PARTY) for expert-initiated LINK method.
* SMS delivery is mocked for now; replace with real SMS provider later. First party opens the link and fills the form via normal v2 flow.
*/
async sendLinkV2(
expert: any,
requestId: string,
): Promise<{
sent: boolean;
linkUrl: string;
sentTo: { role: string; phoneNumber: string }[];
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
throw new BadRequestException(
"This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
const baseUrl =
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
const linkUrl = baseUrl
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
: "";
const sentTo: { role: string; phoneNumber: string }[] = [];
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) {
const phone = req.parties[firstIdx].person.phoneNumber;
this.logger.log(
`[MOCK SMS] Would send link to first party ${phone}: ${linkUrl}`,
);
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone });
}
if (req.type === BlameRequestType.THIRD_PARTY) {
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) {
const phone = req.parties[secondIdx].person.phoneNumber;
this.logger.log(
`[MOCK SMS] Would send link to second party ${phone}: ${linkUrl}`,
);
sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone });
}
}
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
type: "LINK_SENT",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { linkUrl, sentTo },
});
await (req as any).save();
return { sent: true, linkUrl, sentTo };
}
/**
* V2: Send OTP via SMS to one or two parties for expert-initiated IN_PERSON blame.
* Uses the same flow as /user/send-otp (same template, expiry). After this, parties receive SMS; expert collects OTPs and calls verify-party-otps.
*/
async sendPartyOtpsV2(
expert: any,
requestId: string,
dto: { firstPartyPhoneNumber: string; secondPartyPhoneNumber?: string },
): Promise<{ sent: boolean; message: string }> {
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 blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (req.type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber) {
throw new BadRequestException(
"Second party phone number is required for THIRD_PARTY. Provide secondPartyPhoneNumber to send OTP to both parties.",
);
}
const sent: string[] = [];
try {
await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber);
sent.push(dto.firstPartyPhoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
if (dto.secondPartyPhoneNumber) {
try {
await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber);
sent.push(dto.secondPartyPhoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
}
return {
sent: true,
message:
sent.length === 2
? `OTP sent to both parties (${sent.join(", ")}). Have them tell you the code, then call verify-party-otps.`
: `OTP sent to ${sent[0]}. Have the party tell you the code, then call verify-party-otps.`,
};
}
/**
* V2: Verify one or two party OTPs for expert-initiated IN_PERSON blame.
* Call this after send-party-otps (or after parties requested OTP via /user/send-otp). On success, parties are linked to their user ids so the expert can proceed with complete-blame-data.
*/
async verifyPartyOtpsV2(
expert: any,
requestId: string,
dto: {
firstPartyPhoneNumber: string;
firstPartyOtp: string;
secondPartyPhoneNumber?: string;
secondPartyOtp?: string;
},
): Promise<{ verified: boolean; message: string }> {
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 blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
const now = Date.now();
const verifyOne = async (phone: string, otp: string): Promise<Types.ObjectId> => {
const user = await this.userDbService.findOne({
$or: [{ username: phone }, { mobile: phone }],
});
if (!user) throw new BadRequestException(`User not found for phone: ${phone}`);
const u = user as any;
if (u.otp == null) throw new BadRequestException(`No OTP requested for ${phone}. User must call /user/send-otp first.`);
if (u.otpExpire < now) throw new BadRequestException(`OTP expired for ${phone}. User must request a new OTP.`);
const valid = await this.hashService.compare(otp, u.otp);
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
return u._id;
};
const firstUserId = await verifyOne(dto.firstPartyPhoneNumber, dto.firstPartyOtp);
if (!Array.isArray(req.parties)) req.parties = [];
const firstIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found on request");
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = firstUserId;
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber;
if (req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber && dto.secondPartyOtp) {
const secondUserId = await verifyOne(dto.secondPartyPhoneNumber, dto.secondPartyOtp);
let secondIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.SECOND);
if (secondIdx === -1) {
req.parties.push({
role: PartyRole.SECOND,
person: {
userId: secondUserId,
phoneNumber: dto.secondPartyPhoneNumber,
},
} as any);
} else {
if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = secondUserId;
req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber;
}
} else if (req.type === BlameRequestType.THIRD_PARTY && (dto.secondPartyPhoneNumber || dto.secondPartyOtp)) {
throw new BadRequestException("For THIRD_PARTY both secondPartyPhoneNumber and secondPartyOtp are required.");
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "PARTY_OTPS_VERIFIED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {
firstPartyVerified: true,
secondPartyVerified: req.type === BlameRequestType.THIRD_PARTY && !!dto.secondPartyPhoneNumber,
},
} as any);
await (req as any).save();
return {
verified: true,
message: req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber
? "Both parties verified. You can proceed to fill the blame form."
: "First party verified. You can proceed to fill the blame form.",
};
}
/**
* List all expert-initiated blame files for the current field expert (their panel).
* Only files where initiatedBy === current user are returned.
@@ -3684,7 +3950,7 @@ export class RequestManagementService {
driverBirthday: firstPartyPlate.driverBirthday,
},
vehicle: {
plateId: firstPartyPlate.plate,
plateId: this.plateToPlateIdString(firstPartyPlate.plate) || (firstPartyPlate as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
@@ -3723,7 +3989,7 @@ export class RequestManagementService {
req.parties = [firstParty];
req.workflow = {
currentStep: WorkflowStep.COMPLETED,
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
nextStep: undefined,
completedSteps: [
WorkflowStep.CREATED,
@@ -3736,7 +4002,7 @@ export class RequestManagementService {
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.COMPLETED;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
@@ -3827,7 +4093,7 @@ export class RequestManagementService {
driverBirthday: plateDto.driverBirthday,
},
vehicle: {
plateId: plateDto.plate,
plateId: this.plateToPlateIdString(plateDto.plate) || (plateDto as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
@@ -3867,8 +4133,14 @@ export class RequestManagementService {
);
req.parties = [firstParty, secondParty];
const guiltyPartyId =
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber
? String(firstPartyUserId)
: String(secondPartyUserId);
if (!req.expert) req.expert = {} as any;
req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any;
req.workflow = {
currentStep: WorkflowStep.COMPLETED,
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
nextStep: undefined,
completedSteps: [
WorkflowStep.CREATED,
@@ -3885,7 +4157,7 @@ export class RequestManagementService {
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.COMPLETED;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
@@ -4006,6 +4278,116 @@ export class RequestManagementService {
return { requestId: req._id };
}
/**
* V2: Expert uploads a party's signature for expert-initiated IN_PERSON blame.
* CAR_BODY: upload FIRST only. THIRD_PARTY: upload FIRST then SECOND.
* When all required parties have signed, status becomes COMPLETED.
*/
async expertUploadPartySignatureV2(
expert: any,
requestId: string,
partyRole: PartyRole,
isAccept: boolean,
signFile: Express.Multer.File,
): Promise<UserSignatureResponseDto> {
if (!signFile) {
throw new BadRequestException("A signature file is required");
}
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 blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
throw new BadRequestException(
"Request is not waiting for signatures. Current status: " + req.status,
);
}
const partyIndex = this.getPartyIndex(req, partyRole);
if (partyIndex === -1) {
throw new BadRequestException(`Party ${partyRole} not found on this request`);
}
if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.SECOND) {
throw new BadRequestException("CAR_BODY has only first party; use partyRole FIRST.");
}
const party = req.parties[partyIndex];
if (party.confirmation) {
throw new BadRequestException(
`Party ${partyRole} has already signed.`,
);
}
const partyUserId = party.person?.userId ? String(party.person.userId) : undefined;
const signDoc = await this.userSign.create({
fileName: signFile.filename,
userId: partyUserId || expert.sub,
path: signFile.path,
requestId: new Types.ObjectId(requestId),
});
const signatureData = {
fileId: String((signDoc as any)._id),
fileName: signFile.filename,
fileUrl: signFile.path,
};
const updatePayload: any = {
$set: {
[`parties.${partyIndex}.confirmation`]: {
partyRole: party.role,
accepted: isAccept,
signature: signatureData,
},
},
};
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
const updatedRequest = await this.blameRequestDbService.findById(requestId);
const requiredCount = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
const signedParties = (updatedRequest.parties || []).filter(
(p) => p.confirmation != null && p.confirmation !== undefined,
);
let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES;
let message = "Signature recorded successfully.";
if (signedParties.length >= requiredCount) {
const allAccepted = updatedRequest.parties
.slice(0, requiredCount)
.every((p) => p.confirmation?.accepted === true);
if (allAccepted) {
finalStatus = CaseStatus.COMPLETED;
message = "All parties have signed. Blame case completed.";
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.COMPLETED,
"workflow.currentStep": WorkflowStep.COMPLETED as any,
"workflow.nextStep": null,
},
$push: {
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
},
});
} else {
finalStatus = CaseStatus.CANCELLED;
message = "One or both parties rejected. Case requires in-person resolution.";
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.CANCELLED,
"workflow.currentStep": WorkflowStep.COMPLETED as any,
"workflow.nextStep": null,
},
$push: {
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
},
});
}
}
return {
requestId: String(req._id),
accepted: isAccept,
status: finalStatus,
message,
};
}
/**
* V2: Expert adds accident fields to expert-initiated BlameRequest.
*/