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

@@ -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.
*/