forked from Yara724/api
Added expert field mirror flow
This commit is contained in:
@@ -181,7 +181,34 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
private assertPartyOwner(party: any, user: any, errMsg: string) {
|
||||
/**
|
||||
* True when `user` is the field-expert/registrar who created this IN_PERSON
|
||||
* file and is therefore allowed to act on behalf of its parties (fill the
|
||||
* normal granular steps for both parties). Normal USER callers never match.
|
||||
*/
|
||||
private isBlameOnBehalfActor(req: any, user: any): boolean {
|
||||
if (!req || !user) return false;
|
||||
if (req.creationMethod !== CreationMethod.IN_PERSON) return false;
|
||||
if (user.role === RoleEnum.FIELD_EXPERT) {
|
||||
return (
|
||||
!!req.expertInitiated &&
|
||||
!!req.initiatedByFieldExpertId &&
|
||||
String(req.initiatedByFieldExpertId) === String(user.sub)
|
||||
);
|
||||
}
|
||||
if (user.role === RoleEnum.REGISTRAR) {
|
||||
return (
|
||||
!!req.registrarInitiated &&
|
||||
!!req.initiatedByRegistrarId &&
|
||||
String(req.initiatedByRegistrarId) === String(user.sub)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) {
|
||||
// The initiating field-expert/registrar fills steps on behalf of parties.
|
||||
if (req && this.isBlameOnBehalfActor(req, user)) return;
|
||||
const partyUserId = party?.person?.userId
|
||||
? String(party.person.userId)
|
||||
: null;
|
||||
@@ -192,6 +219,30 @@ export class RequestManagementService {
|
||||
if (!ok) throw new ForbiddenException(errMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the on-behalf (expert/registrar) flow only: when the caller explicitly
|
||||
* labels which party a submission belongs to (`partyRole`), make sure it
|
||||
* matches the party the file is currently collecting data for. Keeps the
|
||||
* sequential workflow intact while guaranteeing the expert never attributes a
|
||||
* submission to the wrong party. No-op for normal users (who never pass it).
|
||||
*/
|
||||
private assertExpectedPartyRole(
|
||||
expectedRole: string | undefined,
|
||||
actualRole: PartyRole,
|
||||
onBehalf: boolean,
|
||||
) {
|
||||
if (!onBehalf || expectedRole == null || expectedRole === "") return;
|
||||
const want =
|
||||
String(expectedRole).toUpperCase() === "SECOND"
|
||||
? PartyRole.SECOND
|
||||
: PartyRole.FIRST;
|
||||
if (want !== actualRole) {
|
||||
throw new BadRequestException(
|
||||
`partyRole mismatch: this file is currently collecting the ${actualRole} party's data, but partyRole=${want} was sent. Submit ${actualRole} party data (advance the file to the other party first if needed).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async advanceWorkflowToNext(
|
||||
req: any,
|
||||
submittedStepKey: WorkflowStep,
|
||||
@@ -610,6 +661,7 @@ export class RequestManagementService {
|
||||
requestId: string,
|
||||
body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean },
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
const step2 = await this.getWorkflowStep({ stepNumber: 2 });
|
||||
const step2Key = step2.stepKey as WorkflowStep;
|
||||
@@ -653,13 +705,23 @@ export class RequestManagementService {
|
||||
(firstParty?.person?.phoneNumber &&
|
||||
firstParty.person.phoneNumber === user?.username);
|
||||
|
||||
if (!isOwner) {
|
||||
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
|
||||
throw new ForbiddenException("Only first party can submit this step");
|
||||
}
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
PartyRole.FIRST,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
// Set userId for first party if not already set
|
||||
if (!firstParty.person) firstParty.person = {} as any;
|
||||
if (!firstParty.person.userId && user?.sub) {
|
||||
if (
|
||||
!firstParty.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -755,6 +817,7 @@ export class RequestManagementService {
|
||||
requestId: string,
|
||||
body: { car?: boolean; object?: boolean },
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
@@ -763,12 +826,17 @@ export class RequestManagementService {
|
||||
"This endpoint is only for CAR_BODY type requests",
|
||||
);
|
||||
}
|
||||
if (!req.workflow?.nextStep) {
|
||||
if (!req.workflow?.currentStep) {
|
||||
throw new BadRequestException("Request workflow is not initialized");
|
||||
}
|
||||
if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) {
|
||||
// Normal user flow: currentStep=CREATED, nextStep=CAR_BODY_ACCIDENT_TYPE
|
||||
// Expert IN_PERSON: currentStep=CAR_BODY_ACCIDENT_TYPE, nextStep=FIRST_VIDEO
|
||||
const isCorrectStep =
|
||||
req.workflow.currentStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE ||
|
||||
req.workflow.nextStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
|
||||
if (!isCorrectStep) {
|
||||
throw new BadRequestException(
|
||||
`Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${req.workflow.nextStep}`,
|
||||
`Invalid step. Expected currentStep or nextStep to be CAR_BODY_ACCIDENT_TYPE but got currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -780,10 +848,21 @@ export class RequestManagementService {
|
||||
firstParty,
|
||||
user,
|
||||
"Only first party can submit this step",
|
||||
req,
|
||||
);
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
PartyRole.FIRST,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
if (!firstParty.person) firstParty.person = {} as any;
|
||||
if (!firstParty.person.userId && user?.sub) {
|
||||
if (
|
||||
!firstParty.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -869,7 +948,7 @@ export class RequestManagementService {
|
||||
(firstParty?.person?.phoneNumber &&
|
||||
firstParty.person.phoneNumber === user?.username);
|
||||
|
||||
if (!isOwner) {
|
||||
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
|
||||
throw new ForbiddenException("Only first party can upload this video");
|
||||
}
|
||||
|
||||
@@ -944,7 +1023,12 @@ export class RequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
async initialFormV2(requestId: string, body: AddPlateDto, user: any) {
|
||||
async initialFormV2(
|
||||
requestId: string,
|
||||
body: AddPlateDto,
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
try {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) {
|
||||
@@ -977,11 +1061,22 @@ export class RequestManagementService {
|
||||
party,
|
||||
user,
|
||||
`Only ${role} party can submit this step`,
|
||||
req,
|
||||
);
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
role,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
// Set userId for party if not already set (important for second party)
|
||||
if (!party.person) party.person = {} as any;
|
||||
if (!party.person.userId && user?.sub) {
|
||||
if (
|
||||
!party.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -1333,7 +1428,12 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
async addDetailLocationV2(requestId: string, body: LocationDto, user: any) {
|
||||
async addDetailLocationV2(
|
||||
requestId: string,
|
||||
body: LocationDto,
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.workflow?.currentStep) {
|
||||
@@ -1359,11 +1459,22 @@ export class RequestManagementService {
|
||||
party,
|
||||
user,
|
||||
"Only the related party can submit location",
|
||||
req,
|
||||
);
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
role,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
// Set userId for party if not already set (important for second party)
|
||||
if (!party.person) party.person = {} as any;
|
||||
if (!party.person.userId && user?.sub) {
|
||||
if (
|
||||
!party.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -1399,6 +1510,7 @@ export class RequestManagementService {
|
||||
requestId: string,
|
||||
voice: Express.Multer.File,
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
if (!voice) throw new BadRequestException("File is required");
|
||||
|
||||
@@ -1427,11 +1539,22 @@ export class RequestManagementService {
|
||||
party,
|
||||
user,
|
||||
"Only the related party can upload voice",
|
||||
req,
|
||||
);
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
role,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
// Set userId for party if not already set (important for second party)
|
||||
if (!party.person) party.person = {} as any;
|
||||
if (!party.person.userId && user?.sub) {
|
||||
if (
|
||||
!party.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -1477,7 +1600,12 @@ export class RequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
async addDescriptionV2(requestId: string, body: DescriptionDto, user: any) {
|
||||
async addDescriptionV2(
|
||||
requestId: string,
|
||||
body: DescriptionDto,
|
||||
user: any,
|
||||
expectedRole?: string,
|
||||
) {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.workflow?.currentStep) {
|
||||
@@ -1503,11 +1631,22 @@ export class RequestManagementService {
|
||||
party,
|
||||
user,
|
||||
"Only the related party can submit description",
|
||||
req,
|
||||
);
|
||||
|
||||
this.assertExpectedPartyRole(
|
||||
expectedRole,
|
||||
role,
|
||||
this.isBlameOnBehalfActor(req, user),
|
||||
);
|
||||
|
||||
// Set userId for party if not already set (important for second party)
|
||||
if (!party.person) party.person = {} as any;
|
||||
if (!party.person.userId && user?.sub) {
|
||||
if (
|
||||
!party.person.userId &&
|
||||
user?.sub &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined;
|
||||
@@ -1587,7 +1726,8 @@ export class RequestManagementService {
|
||||
isSecondThirdParty &&
|
||||
closedByMutualAgreement &&
|
||||
req.status === CaseStatus.WAITING_FOR_SIGNATURES &&
|
||||
req.blameStatus === BlameStatus.AGREED
|
||||
req.blameStatus === BlameStatus.AGREED &&
|
||||
!this.isBlameOnBehalfActor(req, user)
|
||||
) {
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
@@ -1628,7 +1768,45 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!closedByMutualAgreement) {
|
||||
const expertOnBehalfThirdPartySecond =
|
||||
!closedByMutualAgreement &&
|
||||
isSecondThirdParty &&
|
||||
this.isBlameOnBehalfActor(req, user);
|
||||
|
||||
if (expertOnBehalfThirdPartySecond) {
|
||||
// Expert-initiated IN_PERSON: the field expert is on the accident scene
|
||||
// and decides guilt directly. By contract the FIRST party (the one the
|
||||
// expert registers first) is always the guilty party, so we record the
|
||||
// decision here and move straight to signatures — there is no
|
||||
// waiting-for-field-expert phase for these files.
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
|
||||
if (!guiltyUserId) {
|
||||
throw new BadRequestException(
|
||||
"First party must be verified (OTP) before finishing the blame file.",
|
||||
);
|
||||
}
|
||||
const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber;
|
||||
const damagedPhone = (req.parties?.[secondIdx]?.person as any)
|
||||
?.phoneNumber;
|
||||
if (!(req as any).expert) (req as any).expert = {};
|
||||
(req as any).expert.decision = {
|
||||
guiltyPartyId: new Types.ObjectId(String(guiltyUserId)),
|
||||
description: `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده میباشد.`,
|
||||
} as any;
|
||||
const completed = Array.isArray(req.workflow.completedSteps)
|
||||
? req.workflow.completedSteps
|
||||
: [];
|
||||
if (!completed.includes(stepKey)) completed.push(stepKey);
|
||||
if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) {
|
||||
completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION);
|
||||
}
|
||||
req.workflow.completedSteps = completed;
|
||||
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
|
||||
req.workflow.nextStep = undefined;
|
||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||
} else if (!closedByMutualAgreement) {
|
||||
if (stepKey === WorkflowStep.SECOND_DESCRIPTION) {
|
||||
req.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||
}
|
||||
@@ -1711,6 +1889,7 @@ export class RequestManagementService {
|
||||
firstParty,
|
||||
user,
|
||||
"Only first party can invite second party",
|
||||
req,
|
||||
);
|
||||
|
||||
// Validate second party phone number is different
|
||||
@@ -1823,6 +2002,113 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expert-initiated IN_PERSON variant of `addSecondPartyV2`.
|
||||
*
|
||||
* The expert is physically with both parties, so there is no invite link/SMS:
|
||||
* we simply ensure the SECOND party exists and advance the workflow from
|
||||
* FIRST_INVITE_SECOND to SECOND_INITIAL_FORM so the expert can fill the
|
||||
* second party's steps using the same granular endpoints.
|
||||
*/
|
||||
async expertAdvanceToSecondPartyV2(
|
||||
requestId: string,
|
||||
user: any,
|
||||
phoneNumber?: string,
|
||||
) {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!this.isBlameOnBehalfActor(req, user)) {
|
||||
throw new ForbiddenException(
|
||||
"Only the initiating expert/registrar can advance this in-person file.",
|
||||
);
|
||||
}
|
||||
if (req.type !== BlameRequestType.THIRD_PARTY) {
|
||||
throw new BadRequestException(
|
||||
"Only THIRD_PARTY files have a second party.",
|
||||
);
|
||||
}
|
||||
if (!req.workflow?.currentStep) {
|
||||
throw new BadRequestException("Request workflow is not initialized");
|
||||
}
|
||||
const stepKey = req.workflow.currentStep as WorkflowStep;
|
||||
if (stepKey !== WorkflowStep.FIRST_INVITE_SECOND) {
|
||||
throw new BadRequestException(
|
||||
`Invalid step. Expected FIRST_INVITE_SECOND but got ${stepKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new BadRequestException(
|
||||
"phoneNumber is required to register the second party",
|
||||
);
|
||||
}
|
||||
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
if (
|
||||
firstIdx !== -1 &&
|
||||
(req.parties[firstIdx]?.person as any)?.phoneNumber === phoneNumber
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Second party phone number cannot be the same as first party",
|
||||
);
|
||||
}
|
||||
|
||||
// Create second party placeholder (no userId yet — will be bound after OTP verify)
|
||||
let secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
if (secondIdx === -1) {
|
||||
if (!Array.isArray(req.parties)) req.parties = [];
|
||||
req.parties.push({
|
||||
role: PartyRole.SECOND,
|
||||
person: { phoneNumber } as any,
|
||||
} as any);
|
||||
} else {
|
||||
if (!req.parties[secondIdx].person)
|
||||
req.parties[secondIdx].person = {} as any;
|
||||
(req.parties[secondIdx].person as any).phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
// Send OTP to second party — expert collects it from them in person
|
||||
try {
|
||||
await this.userAuthService.sendOtpRequest(phoneNumber);
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes("Wait for expiry")) {
|
||||
throw new BadRequestException(
|
||||
`(${phoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Stay at FIRST_INVITE_SECOND — workflow will advance only after verifyPartyOtp
|
||||
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
|
||||
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "SECOND_PARTY_OTP_SENT",
|
||||
actor: {
|
||||
actorId: Types.ObjectId.isValid(user?.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined,
|
||||
actorName: user?.fullName,
|
||||
actorType: "user",
|
||||
},
|
||||
metadata: {
|
||||
inPersonExpert: true,
|
||||
phoneNumber,
|
||||
note: "OTP sent; call verify-party-otp to bind and advance to SECOND_INITIAL_FORM",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await (req as any).save();
|
||||
|
||||
return {
|
||||
requestId: req._id,
|
||||
publicId: req.publicId,
|
||||
workflow: req.workflow,
|
||||
message: `OTP sent to ${phoneNumber}. Collect the code and call verify-party-otp.`,
|
||||
};
|
||||
}
|
||||
|
||||
// async createRequest(user, type) {
|
||||
// const reqNumber = new ShortUniqueId({ counter: 1 });
|
||||
// const publicId = await this.publicIdService.generateRequestPublicId();
|
||||
@@ -4036,30 +4322,6 @@ export class RequestManagementService {
|
||||
? 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(
|
||||
@@ -4079,37 +4341,10 @@ export class RequestManagementService {
|
||||
|
||||
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: {},
|
||||
});
|
||||
}
|
||||
// Always start with an empty first-party placeholder.
|
||||
// For LINK files the phone + userId are stored when send-link is called.
|
||||
// For IN_PERSON files the phone + userId are stored when verify-party-otp is called.
|
||||
const parties: any[] = [{ role: PartyRole.FIRST, person: {} }];
|
||||
|
||||
const created = await this.blameRequestDbService.create({
|
||||
publicId,
|
||||
@@ -4170,63 +4405,49 @@ export class RequestManagementService {
|
||||
async sendLinkV2(
|
||||
expert: any,
|
||||
requestId: string,
|
||||
dto: { frontendRoute: string },
|
||||
dto: { phoneNumber: string },
|
||||
): Promise<{
|
||||
sent: boolean;
|
||||
linkUrl: string;
|
||||
sentTo: { role: string; phoneNumber: string; smsSent: boolean }[];
|
||||
sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: 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 endpoint is only for expert-initiated LINK blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, expert);
|
||||
|
||||
if (!dto?.frontendRoute) {
|
||||
throw new BadRequestException("frontendRoute is required");
|
||||
}
|
||||
const phone = (dto?.phoneNumber || "").trim();
|
||||
if (!phone) throw new BadRequestException("phoneNumber is required");
|
||||
|
||||
if (!process.env.URL) {
|
||||
throw new InternalServerErrorException(
|
||||
"URL environment variable is not configured",
|
||||
);
|
||||
}
|
||||
const linkUrl = this.smsOrchestrationService.buildInviteLink(
|
||||
dto.frontendRoute,
|
||||
requestId,
|
||||
);
|
||||
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
|
||||
|
||||
const sentTo: { role: string; phoneNumber: string; smsSent: boolean }[] =
|
||||
[];
|
||||
const sendSms = async (
|
||||
phone: string,
|
||||
role: PartyRole,
|
||||
): Promise<boolean> => {
|
||||
return this.smsOrchestrationService.sendFieldExpertLink({
|
||||
receptor: phone,
|
||||
type: req.type,
|
||||
expertLastName: expertName,
|
||||
link: linkUrl,
|
||||
});
|
||||
};
|
||||
|
||||
// Store / update the first party's phone and bind a user account
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) {
|
||||
const phone = req.parties[firstIdx].person.phoneNumber;
|
||||
const smsSent = await sendSms(phone, PartyRole.FIRST);
|
||||
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent });
|
||||
}
|
||||
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;
|
||||
const smsSent = await sendSms(phone, PartyRole.SECOND);
|
||||
sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone, smsSent });
|
||||
}
|
||||
}
|
||||
if (firstIdx === -1)
|
||||
throw new BadRequestException("First party not found on request");
|
||||
const userId = await this.getOrCreateUserByPhoneNumber(phone);
|
||||
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||||
req.parties[firstIdx].person.phoneNumber = phone;
|
||||
req.parties[firstIdx].person.userId = userId;
|
||||
|
||||
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
|
||||
const sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] = [];
|
||||
|
||||
const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST");
|
||||
const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({
|
||||
receptor: phone,
|
||||
type: req.type,
|
||||
expertLastName: expertName,
|
||||
link: firstLink,
|
||||
});
|
||||
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink });
|
||||
|
||||
if (!Array.isArray((req as any).history)) (req as any).history = [];
|
||||
(req as any).history.push({
|
||||
@@ -4236,18 +4457,12 @@ export class RequestManagementService {
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
metadata: {
|
||||
linkUrl,
|
||||
sentTo,
|
||||
template: "yara-field-expert-link",
|
||||
frontendRoute: dto.frontendRoute,
|
||||
},
|
||||
metadata: { sentTo, template: "yara-field-expert-link" },
|
||||
});
|
||||
await (req as any).save();
|
||||
|
||||
return {
|
||||
sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent),
|
||||
linkUrl,
|
||||
sentTo,
|
||||
};
|
||||
}
|
||||
@@ -4440,6 +4655,279 @@ export class RequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an OTP to a single party phone (one-at-a-time IN_PERSON flow).
|
||||
* The expert sends to the first party, collects the code and verifies, fills
|
||||
* their data, then enters the second party's phone and repeats — instead of
|
||||
* sending an invite link, the OTP registers/authenticates the second party.
|
||||
*/
|
||||
async sendPartyOtpV2(
|
||||
actor: any,
|
||||
requestId: string,
|
||||
dto: { phoneNumber: 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.registrarInitiated) ||
|
||||
req.creationMethod !== CreationMethod.IN_PERSON
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, actor);
|
||||
const phone = (dto?.phoneNumber || "").trim();
|
||||
if (!phone) throw new BadRequestException("phoneNumber is required");
|
||||
try {
|
||||
await this.userAuthService.sendOtpRequest(phone);
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes("Wait for expiry")) {
|
||||
throw new BadRequestException(
|
||||
`(${phone}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
sent: true,
|
||||
message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a single party OTP and bind that party's user account.
|
||||
* `partyRole` is optional: when omitted, FIRST is bound if it has no user yet,
|
||||
* otherwise SECOND (created if missing). For THIRD_PARTY the second party is
|
||||
* registered here instead of receiving an invite link.
|
||||
*/
|
||||
async verifyPartyOtpV2(
|
||||
actor: any,
|
||||
requestId: string,
|
||||
dto: { phoneNumber: string; otp: string; partyRole?: string },
|
||||
): Promise<{ verified: boolean; partyRole: PartyRole; message: string }> {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (
|
||||
(!req.expertInitiated && !req.registrarInitiated) ||
|
||||
req.creationMethod !== CreationMethod.IN_PERSON
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, actor);
|
||||
|
||||
const phone = (dto?.phoneNumber || "").trim();
|
||||
const otp = (dto?.otp || "").trim();
|
||||
if (!phone || !otp) {
|
||||
throw new BadRequestException("phoneNumber and otp are required");
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.parties)) req.parties = [];
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
|
||||
// Resolve which party this phone belongs to.
|
||||
let role: PartyRole;
|
||||
if (dto.partyRole === "SECOND" || dto.partyRole === "FIRST") {
|
||||
role = dto.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
} else {
|
||||
const firstHasUser = !!(
|
||||
firstIdx !== -1 && req.parties[firstIdx]?.person?.userId
|
||||
);
|
||||
role = firstHasUser ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
}
|
||||
|
||||
if (
|
||||
role === PartyRole.SECOND &&
|
||||
req.type !== BlameRequestType.THIRD_PARTY
|
||||
) {
|
||||
throw new BadRequestException("CAR_BODY has only one (first) party.");
|
||||
}
|
||||
|
||||
// Verify the OTP and resolve the user id.
|
||||
const now = Date.now();
|
||||
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}. Send an OTP first.`,
|
||||
);
|
||||
if (u.otpExpire < now)
|
||||
throw new BadRequestException(
|
||||
`OTP expired for ${phone}. Request a new OTP.`,
|
||||
);
|
||||
const valid = await this.hashService.compare(otp, u.otp);
|
||||
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
|
||||
const userId = u._id as Types.ObjectId;
|
||||
|
||||
if (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 = userId;
|
||||
req.parties[firstIdx].person.phoneNumber = phone;
|
||||
} else {
|
||||
const firstPhone =
|
||||
firstIdx !== -1
|
||||
? (req.parties[firstIdx]?.person as any)?.phoneNumber
|
||||
: undefined;
|
||||
if (firstPhone && firstPhone === phone) {
|
||||
throw new BadRequestException(
|
||||
"Second party phone number cannot be the same as first party.",
|
||||
);
|
||||
}
|
||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
if (secondIdx === -1) {
|
||||
req.parties.push({
|
||||
role: PartyRole.SECOND,
|
||||
person: { userId, phoneNumber: phone },
|
||||
} as any);
|
||||
} else {
|
||||
if (!req.parties[secondIdx].person)
|
||||
req.parties[secondIdx].person = {} as any;
|
||||
req.parties[secondIdx].person.userId = userId;
|
||||
req.parties[secondIdx].person.phoneNumber = phone;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "PARTY_OTP_VERIFIED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
actorType:
|
||||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||
},
|
||||
metadata: { partyRole: role, phoneNumber: phone },
|
||||
} as any);
|
||||
|
||||
// For IN_PERSON expert/registrar flow: after first party OTP is verified and
|
||||
// the workflow is still at CREATED, advance past any intro step to the first
|
||||
// real data-entry step.
|
||||
//
|
||||
// THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and
|
||||
// land on FIRST_VIDEO.
|
||||
// CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE
|
||||
// so the expert can fill the car-body form before video.
|
||||
if (
|
||||
role === PartyRole.FIRST &&
|
||||
req.workflow?.currentStep === WorkflowStep.CREATED
|
||||
) {
|
||||
const completed = Array.isArray(req.workflow.completedSteps)
|
||||
? req.workflow.completedSteps
|
||||
: [];
|
||||
|
||||
if (req.type === BlameRequestType.CAR_BODY) {
|
||||
// Advance CREATED → CAR_BODY_ACCIDENT_TYPE
|
||||
// nextStep was already set to CAR_BODY_ACCIDENT_TYPE at creation time;
|
||||
// look up its own nextPossibleSteps so we can set nextStep correctly.
|
||||
const carBodyStepDoc = await this.getWorkflowStep({
|
||||
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
||||
});
|
||||
const afterCarBody =
|
||||
(carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ??
|
||||
WorkflowStep.FIRST_VIDEO;
|
||||
|
||||
req.workflow.completedSteps = completed;
|
||||
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
|
||||
req.workflow.nextStep = afterCarBody;
|
||||
|
||||
req.history.push({
|
||||
type: "AUTO_ADVANCED_TO_CAR_BODY_FORM",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName:
|
||||
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
actorType:
|
||||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||
},
|
||||
metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE },
|
||||
} as any);
|
||||
} else {
|
||||
// THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON
|
||||
const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION
|
||||
const step2Key = step2.stepKey as WorkflowStep;
|
||||
const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO
|
||||
const step3Key = step3.stepKey as WorkflowStep;
|
||||
const nextAfterVideo =
|
||||
(step3.nextPossibleSteps?.[0] as WorkflowStep) ??
|
||||
WorkflowStep.FIRST_INITIAL_FORM;
|
||||
|
||||
if (!completed.includes(step2Key)) completed.push(step2Key);
|
||||
req.workflow.completedSteps = completed;
|
||||
req.workflow.currentStep = step3Key;
|
||||
req.workflow.nextStep = nextAfterVideo;
|
||||
|
||||
// Auto-guilt: first party is always guilty in expert-initiated IN_PERSON
|
||||
const fIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
if (fIdx !== -1) {
|
||||
if (!req.parties[fIdx].statement)
|
||||
req.parties[fIdx].statement = {} as any;
|
||||
req.parties[fIdx].statement.admitsGuilt = true;
|
||||
req.parties[fIdx].statement.claimsDamage = false;
|
||||
req.parties[fIdx].statement.acceptsExpertOpinion = false;
|
||||
}
|
||||
req.blameStatus = BlameStatus.AGREED;
|
||||
|
||||
req.history.push({
|
||||
type: "AUTO_CONFESSION_SKIPPED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName:
|
||||
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
actorType:
|
||||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||
},
|
||||
metadata: {
|
||||
reason:
|
||||
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved",
|
||||
stepKey: step2Key,
|
||||
advancedTo: step3Key,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
// For IN_PERSON expert/registrar flow: after second party OTP is verified and
|
||||
// the workflow is at FIRST_INVITE_SECOND, advance to SECOND_INITIAL_FORM.
|
||||
if (
|
||||
role === PartyRole.SECOND &&
|
||||
req.workflow?.currentStep === WorkflowStep.FIRST_INVITE_SECOND
|
||||
) {
|
||||
await this.advanceWorkflowToNext(req, WorkflowStep.FIRST_INVITE_SECOND);
|
||||
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
|
||||
|
||||
req.history.push({
|
||||
type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
actorType:
|
||||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||
},
|
||||
metadata: {
|
||||
phoneNumber: phone,
|
||||
advancedTo: WorkflowStep.SECOND_INITIAL_FORM,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
await (req as any).save();
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
partyRole: role,
|
||||
message: `${role} party verified and bound to phone ${phone}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all expert-initiated blame files for the current field expert (their panel).
|
||||
* Only files where initiatedBy === current user are returned.
|
||||
@@ -5659,17 +6147,72 @@ export class RequestManagementService {
|
||||
|
||||
if (!req.expert) req.expert = {} as any;
|
||||
if (!req.expert.decision) req.expert.decision = {} as any;
|
||||
|
||||
// Ensure guilty party is recorded (first party is always guilty in IN_PERSON flow)
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
|
||||
const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber;
|
||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber;
|
||||
|
||||
(req.expert as any).decision = {
|
||||
...(req.expert as any).decision,
|
||||
guiltyPartyId: guiltyUserId
|
||||
? new Types.ObjectId(String(guiltyUserId))
|
||||
: (req.expert as any).decision?.guiltyPartyId,
|
||||
description:
|
||||
(req.expert as any).decision?.description ||
|
||||
`مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده میباشد.`,
|
||||
decidedAt: (req.expert as any).decision?.decidedAt || new Date(),
|
||||
decidedByExpertId: new Types.ObjectId(String(expert.sub)),
|
||||
fields: {
|
||||
accidentWay: fields.accidentWay,
|
||||
accidentReason: fields.accidentReason,
|
||||
accidentType: fields.accidentType,
|
||||
},
|
||||
};
|
||||
|
||||
// For IN_PERSON expert files: after accident fields are filled, guilt is
|
||||
// decided and we can go straight to signatures (no separate review needed).
|
||||
if (req.creationMethod === CreationMethod.IN_PERSON) {
|
||||
const completed = Array.isArray(req.workflow?.completedSteps)
|
||||
? req.workflow.completedSteps
|
||||
: [];
|
||||
const currentStep = req.workflow?.currentStep as WorkflowStep | undefined;
|
||||
if (
|
||||
currentStep &&
|
||||
!completed.includes(currentStep)
|
||||
) {
|
||||
completed.push(currentStep);
|
||||
}
|
||||
if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) {
|
||||
completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION);
|
||||
}
|
||||
if (req.workflow) {
|
||||
req.workflow.completedSteps = completed;
|
||||
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
|
||||
req.workflow.nextStep = undefined;
|
||||
}
|
||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(String(expert.sub)),
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
metadata: {
|
||||
accidentWay: fields.accidentWay,
|
||||
advancedTo: WorkflowStep.WAITING_FOR_SIGNATURES,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
await (req as any).save();
|
||||
|
||||
return { requestId: req._id };
|
||||
return { requestId: req._id, workflow: req.workflow, status: req.status };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user