1
0
forked from Yara724/api
Files
yara724-api/src/request-management/request-management.service.ts
SepehrYahyaee 2cc96f6132 Fixed v2 flow
2026-07-22 14:52:51 +03:30

10049 lines
335 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadGatewayException,
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ExternalExceptionFilter } from "@nestjs/core/exceptions/external-exception-filter";
import { Types } from "mongoose";
import { ClientService } from "src/client/client.service";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import {
CarBodyFormDto,
CarBodySecondForm,
InitialFormDto,
InitialFormVinDto,
} from "src/request-management/dto/create-request-management.dto";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import {
UserResendResponseDto,
UserResendUploadResponseDto,
} from "./dto/user-resend.dto";
import { UserSignatureResponseDto } from "./dto/user-signature.dto";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import {
buildResendItemsWithUi,
cloneResendUploadedDocuments,
isResendPartyItemSatisfied,
normalizeResendRequestedItemKey,
normalizeResendRequestedItemsList,
} from "src/Types&Enums/blame-request-management/resend-item-ui";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto";
import { SandHubService } from "src/sand-hub/sand-hub.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import {
DescriptionDto,
LocationDto,
RequestManagementDtoRs,
} from "./dto/create-request-management.dto";
import { DetailsReplyDtoRs, UserReplyDto } from "./dto/user-reply.dto";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { UserSignDbService } from "./entities/db-service/sign.db.service";
import { RequestManagementModel } from "./entities/schema/request-management.schema";
import { BlameDocumentType } from "./entities/schema/blame-document.schema";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import {
CreationMethod,
FilledBy,
FileHistoryEvent,
ExpertLinkInfo,
} from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { PublicIdService } from "src/utils/public-id/public-id.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-service/workflow-step.db.service";
import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
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";
import {
buildMutualAgreementExpertDecision,
MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
} from "src/helpers/blame-party-agreement-decision";
import { buildFileLink } from "src/helpers/urlCreator";
import {
buildUserLookupByPhone,
iranMobileLookupVariants,
normalizeIranMobile,
partyPersonMatchesUser,
} from "src/helpers/iran-mobile";
import {
buildBlamePartyAccessOrConditions,
collectUserIdVariants,
} from "src/helpers/party-access-queries";
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
@Injectable()
export class RequestManagementService {
private readonly logger = new Logger(RequestManagementService.name);
private stepKeyToPartyRole(stepKey: WorkflowStep): PartyRole {
if (String(stepKey).startsWith("FIRST_")) return PartyRole.FIRST;
if (String(stepKey).startsWith("SECOND_")) return PartyRole.SECOND;
throw new BadRequestException(`Step ${stepKey} is not a party-scoped step`);
}
/** 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)
: -1;
}
private normalizeInquiryError(err: any): any {
if (!err) return undefined;
return {
message: err?.message || String(err),
status: err?.response?.status,
data: err?.response?.data,
};
}
private recordCaseInquiryStatus(
req: any,
key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string,
has: boolean,
data: any = {},
error?: any,
): void {
const existingInquiries =
req.inquiries?.toObject?.() ??
(req.inquiries && typeof req.inquiries === "object" ? req.inquiries : {});
req.inquiries = {
...existingInquiries,
[key]: {
has,
data: data ?? {},
...(error ? { error: this.normalizeInquiryError(error) } : {}),
updatedAt: new Date(),
},
};
}
private recordPartyCaseInquiryStatus(
req: any,
key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string,
role: PartyRole,
has: boolean,
data: any = {},
error?: any,
): void {
const existingData =
req?.inquiries?.[key]?.data?.toObject?.() ?? req?.inquiries?.[key]?.data;
const dataByRole =
existingData &&
typeof existingData === "object" &&
!Array.isArray(existingData)
? existingData
: {};
this.recordCaseInquiryStatus(
req,
key,
has,
{
...dataByRole,
[role]: data ?? {},
},
error,
);
}
/**
* 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;
if (partyPersonMatchesUser(party?.person, user)) return;
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,
) {
// CAR_BODY: after first-party description, move to signature step.
if (
req.type === BlameRequestType.CAR_BODY &&
submittedStepKey === WorkflowStep.FIRST_DESCRIPTION
) {
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(submittedStepKey))
completed.push(submittedStepKey);
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.workflow.nextStep = undefined;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
this.logger.debug(
"[WORKFLOW] CAR_BODY: advanced to WAITING_FOR_SIGNATURES after FIRST_DESCRIPTION",
);
return;
}
// IN_PERSON THIRD_PARTY: guilt is decided on-site by the expert — there is
// no waiting-for-field-expert phase. Replace WAITING_FOR_GUILT_DECISION
// with WAITING_FOR_SIGNATURES wherever it would surface as current/next step.
const isInPersonThirdParty =
req.type === BlameRequestType.THIRD_PARTY &&
req.creationMethod === CreationMethod.IN_PERSON &&
(req.expertInitiated || req.registrarInitiated);
const resolveStep = (step: WorkflowStep | undefined): WorkflowStep | undefined => {
if (
isInPersonThirdParty &&
step === WorkflowStep.WAITING_FOR_GUILT_DECISION
) {
return WorkflowStep.WAITING_FOR_SIGNATURES;
}
return step;
};
const submittedStep = await this.getWorkflowStep({
stepKey: submittedStepKey,
});
this.logger.debug(
`[WORKFLOW] Advancing from ${submittedStepKey} (stepNumber=${submittedStep.stepNumber})`,
);
// completedSteps includes the step that was just submitted
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey);
req.workflow.completedSteps = completed;
// Prefer sequential stepNumber, fallback to nextPossibleSteps for branching/end-of-flow
const sequentialNextStep =
await this.workflowStepDbService.findByStepNumber(
submittedStep.stepNumber + 1,
);
const nextStepKey = resolveStep(
(sequentialNextStep?.stepKey ??
submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined,
);
if (!nextStepKey) {
// Terminal state: no more steps
req.workflow.currentStep = submittedStepKey;
req.workflow.nextStep = undefined;
this.logger.debug(
`[WORKFLOW] Reached terminal state at ${submittedStepKey}`,
);
return;
}
// Try to get the next step doc; if it doesn't exist in DB, it's a "waiting state" (valid enum but not actionable)
let nextStepDoc: WorkflowStepModel | null = null;
try {
nextStepDoc =
sequentialNextStep ??
(await this.workflowStepDbService.findByStepKey(nextStepKey));
} catch (err) {
// Step exists in enum but not in DB → it's a waiting/terminal state
this.logger.warn(
`[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`,
);
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = undefined;
return;
}
if (!nextStepDoc) {
// Same case: valid enum, not in DB
this.logger.warn(
`[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`,
);
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = undefined;
return;
}
// For nextStep (what comes after currentStep), prefer sequential, fallback to nextPossibleSteps
const sequentialAfterNext =
await this.workflowStepDbService.findByStepNumber(
nextStepDoc.stepNumber + 1,
);
const afterNextKey = (sequentialAfterNext?.stepKey ??
nextStepDoc.nextPossibleSteps?.[0]) as WorkflowStep | undefined;
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = resolveStep(afterNextKey);
this.logger.debug(
`[WORKFLOW] Advanced to currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep ?? "undefined"}`,
);
}
/**
* Ensures CAR_BODY_ACCIDENT_TYPE step exists in DB (e.g. if initialize was not run after adding CAR_BODY flow).
*/
private async ensureCarBodyAccidentTypeStepExists(): Promise<void> {
const exists = await this.workflowStepDbService.exists(
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
);
if (exists) return;
this.logger.log(
"[WORKFLOW] CAR_BODY_ACCIDENT_TYPE step missing; upserting from default definition",
);
await this.workflowStepDbService.upsertByStepKey(
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
{
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
type: "CAR_BODY",
stepNumber: 2,
isActive: true,
stepName_fa: "نوع حادثه بدنه",
stepName_en: "Car Body Accident Type",
description_fa: "انتخاب اینکه حادثه با خودرو بوده یا با شیء",
description_en:
"Select whether accident was with another car or with an object",
category: "FIRST_PARTY",
requiredPreviousSteps: [WorkflowStep.CREATED],
nextPossibleSteps: [WorkflowStep.FIRST_VIDEO],
allowedRoles: ["user"],
estimatedDuration: 1,
fields: [
{
id: "507f1f77bcf86cd7994390cb" as any,
name_fa: "نوع حادثه",
name_en: "accidentType",
label_fa: "نوع حادثه",
label_en: "Accident Type",
placeholder_fa: "حادثه با خودرو یا شیء؟",
placeholder_en: "Accident with car or object?",
value: [
{ label_fa: "با خودرو", label_en: "With Car", value: "car" },
{ label_fa: "با شیء", label_en: "With Object", value: "object" },
],
dataType: "select",
required: true,
order: 1,
isActive: true,
validation: { enum: ["car", "object"] },
},
],
metadata: { icon: "car", color: "#10B981" },
} as any,
);
}
private async getWorkflowStep(params: {
stepNumber?: number;
stepKey?: WorkflowStep;
}): Promise<WorkflowStepModel> {
const { stepNumber, stepKey } = params;
if (typeof stepNumber !== "number" && !stepKey) {
throw new BadRequestException("stepNumber or stepKey is required");
}
const stepByNumber =
typeof stepNumber === "number"
? await this.workflowStepDbService.findByStepNumber(stepNumber)
: null;
const stepByKey = stepKey
? await this.workflowStepDbService.findByStepKey(stepKey)
: null;
const step = stepByNumber ?? stepByKey;
if (!step) {
throw new InternalServerErrorException(
`Workflow step not found (${stepNumber ? `stepNumber=${stepNumber}` : ""}${
stepNumber && stepKey ? ", " : ""
}${stepKey ? `stepKey=${stepKey}` : ""})`,
);
}
// If caller passed both, ensure they resolve to the same step.
if (
stepByNumber &&
stepByKey &&
stepByNumber.stepKey !== stepByKey.stepKey
) {
throw new InternalServerErrorException(
`Workflow step mismatch: stepNumber=${stepNumber} is ${stepByNumber.stepKey}, but stepKey=${stepKey} is stepNumber=${stepByKey.stepNumber}`,
);
}
return step;
}
constructor(
private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly userDbService: UserDbService,
private readonly sandHubService: SandHubService,
private readonly sandHubDbService: SandHubDbService,
private readonly clientService: ClientService,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly userSign: UserSignDbService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertDbService: ExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly autoCloseRequestService: AutoCloseRequestService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly publicIdService: PublicIdService,
private readonly workflowStepDbService: WorkflowStepDbService,
private readonly hashService: HashService,
private readonly userAuthService: UserAuthService,
) {}
/**
* Reject CAR_BODY submissions whose accident is older than the per-client
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
* scoped to CAR_BODY because THIRD_PARTY files do not record accidentDate
* during the description step.
*
* `clientId` is best-effort — pass `party.person.clientId`,
* `firstPartyDetails.firstPartyClient.clientId`, the SandHub-resolved
* `client._id`, etc., whatever is available at the call site. When the
* value is missing or invalid the helper falls back to the system default
* window so the gate is still enforced.
*
* Throws `BadRequestException` when the accident instant cannot be parsed,
* is in the future, or is older than the configured window.
*/
private async assertCarBodyAccidentWithinWindow(params: {
clientId?: string | Types.ObjectId | null;
accidentDate: Date | string | null | undefined;
accidentTime?: string | null;
}): Promise<void> {
const { clientId, accidentDate, accidentTime } = params;
if (accidentDate == null) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_REQUIRED",
message:
"CAR_BODY files require an accident date to enforce the submission window.",
});
}
const instant = this.parseAccidentInstant(
accidentDate,
accidentTime ?? undefined,
);
if (!instant || Number.isNaN(instant.getTime())) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_INVALID",
message: `Invalid accidentDate/accidentTime: "${String(accidentDate)}"${
accidentTime ? ` "${accidentTime}"` : ""
}.`,
});
}
const ageMs = Date.now() - instant.getTime();
if (ageMs < 0) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
message: "Accident date cannot be in the future.",
});
}
const maxAgeDays = await this.clientService.getCarBodyAccidentMaxAgeDays(
clientId ?? undefined,
);
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays > maxAgeDays) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_TOO_OLD",
message:
`CAR_BODY files must be submitted within ${maxAgeDays} day(s) of the accident. ` +
`This accident occurred about ${Math.floor(ageDays)} day(s) ago.`,
maxAgeDays,
ageDays: Math.floor(ageDays),
});
}
}
/**
* Best-effort parser that combines a date input with an optional `HH:MM`
* time in Iran (Asia/Tehran, UTC+3:30). Accepts a `Date`, an ISO datetime
* string, or an ISO date-only string so DTO payloads (which send
* `"YYYY-MM-DD"` + `"HH:MM"` in local Iran time) can be passed through
* directly. Returns `null` when the result is not a finite instant.
*/
private parseAccidentInstant(
date: Date | string,
time?: string,
): Date | null {
return parseIranLocalDateTime(date, time);
}
/**
* Linked claim cases: block user on damage flow while blame awaits document resend.
*/
async applyLinkedClaimsBlameResendStarted(
blameRequestId: string,
): Promise<void> {
const oid = new Types.ObjectId(blameRequestId);
const claims = await this.claimCaseDbService.find({
blameRequestId: oid,
});
for (const c of claims as any[]) {
const st = c.status as ClaimCaseStatus;
if (
st === ClaimCaseStatus.COMPLETED ||
st === ClaimCaseStatus.CANCELLED ||
st === ClaimCaseStatus.REJECTED
) {
continue;
}
await this.claimCaseDbService.findByIdAndUpdate(c._id, {
$set: {
blameDocumentResendPending: true,
claimStatus: ClaimStatus.NEEDS_REVISION,
},
$push: {
history: {
type: "BLAME_DOCUMENT_RESEND_STARTED",
timestamp: new Date(),
metadata: { blameRequestId },
},
},
});
}
}
async applyLinkedClaimsBlameResendCleared(
blameRequestId: string,
): Promise<void> {
const oid = new Types.ObjectId(blameRequestId);
const claims = await this.claimCaseDbService.find({
blameRequestId: oid,
});
for (const c of claims as any[]) {
if (!c.blameDocumentResendPending) {
continue;
}
await this.claimCaseDbService.findByIdAndUpdate(c._id, {
$set: {
blameDocumentResendPending: false,
claimStatus: ClaimStatus.PENDING,
},
$push: {
history: {
type: "BLAME_DOCUMENT_RESEND_CLEARED",
timestamp: new Date(),
metadata: { blameRequestId },
},
},
});
}
}
async createRequestV2(user: any, type: BlameRequestType) {
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
if (!nextStepFromManager) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
// CAR_BODY skips confession and goes to accident-type form
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
const publicId = await this.publicIdService.generateRequestPublicId();
const created = await this.blameRequestDbService.create({
// we keep both fields for now; the shared spoken id is `publicId`
publicId,
requestNo: publicId,
type,
parties: [
{
role: PartyRole.FIRST,
person: {
userId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
clientId: undefined,
phoneNumber: user?.username,
fullName: user?.fullName,
},
},
],
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
});
return { requestId: created._id, publicId };
}
async blameConfessionV2(
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;
const nextStepFromManager = step2.nextPossibleSteps?.[0];
if (!nextStepFromManager) {
throw new InternalServerErrorException(
"Workflow stepNumber=2 has no nextPossibleSteps configured",
);
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
if (!req.workflow?.nextStep) {
throw new BadRequestException("Request workflow is not initialized");
}
// Validate step ordering: only allow this API when nextStep == step #2 key
if (req.workflow.nextStep !== step2Key) {
throw new BadRequestException(
`Invalid step. Expected nextStep=${step2Key} but got ${req.workflow.nextStep}`,
);
}
const firstPartyIndex = Array.isArray(req.parties)
? req.parties.findIndex((p) => p?.role === PartyRole.FIRST)
: -1;
if (firstPartyIndex === -1) {
throw new BadRequestException("First party not found on request");
}
const firstParty = req.parties[firstPartyIndex];
const firstPartyUserId = firstParty?.person?.userId
? String(firstParty.person.userId)
: null;
const isOwner =
(firstPartyUserId && firstPartyUserId === String(user?.sub)) ||
(firstParty?.person?.phoneNumber &&
firstParty.person.phoneNumber === user?.username);
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 &&
!this.isBlameOnBehalfActor(req, user)
) {
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
const imGuilty = !!body?.imGuilty;
const imDamaged = !!body?.imDamaged;
const expertOpinion = !!body?.expertOpinion;
let blameStatus: BlameStatus;
let resolvedExpertOpinion = false;
if (imGuilty || imDamaged) {
blameStatus = BlameStatus.AGREED;
resolvedExpertOpinion = false;
} else if (expertOpinion) {
blameStatus = BlameStatus.DISAGREEMENT;
resolvedExpertOpinion = true;
} else {
throw new BadRequestException(
"Invalid body: set imGuilty or imDamaged for AGREED, or expertOpinion for DISAGREEMENT",
);
}
// Persist into first party statement (new model)
if (!firstParty.statement) {
firstParty.statement = {} as any;
}
firstParty.statement.admitsGuilt = imGuilty;
firstParty.statement.claimsDamage = imDamaged;
firstParty.statement.acceptsExpertOpinion = resolvedExpertOpinion;
// Update top-level blameStatus
req.blameStatus = blameStatus;
// Advance workflow
// completedSteps should include the step that was just submitted (step2Key),
// but currentStep should move forward to the next step to be done.
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(step2Key)) {
completed.push(step2Key);
}
req.workflow.completedSteps = completed;
const nextStepKey = nextStepFromManager as WorkflowStep; // e.g. FIRST_VIDEO
const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey });
const afterNext = nextStepDoc.nextPossibleSteps?.[0];
if (!afterNext) {
throw new InternalServerErrorException(
`Workflow step ${nextStepKey} has no nextPossibleSteps configured`,
);
}
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = afterNext as WorkflowStep; // e.g. FIRST_INITIAL_FORM
// History
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "FIRST_BLAME_CONFESSION_SUBMITTED",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
imGuilty,
imDamaged,
expertOpinion: resolvedExpertOpinion,
blameStatus,
stepKey: step2Key,
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
blameStatus: req.blameStatus,
workflow: req.workflow,
};
}
/**
* V2 CAR_BODY: Submit accident type form (car vs object). Replaces confession step for CAR_BODY.
*/
async carBodyAccidentTypeFormV2(
requestId: string,
body: { car?: boolean; object?: boolean },
user: any,
expectedRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException(
"This endpoint is only for CAR_BODY type requests",
);
}
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
// 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 currentStep or nextStep to be CAR_BODY_ACCIDENT_TYPE but got currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep}`,
);
}
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
this.assertPartyOwner(
firstParty,
user,
"Only first party can submit this step",
req,
);
this.assertExpectedPartyRole(
expectedRole,
PartyRole.FIRST,
this.isBlameOnBehalfActor(req, user),
);
if (!firstParty.person) firstParty.person = {} as any;
if (
!firstParty.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
const car = !!body?.car;
const object = !!body?.object;
if (!car && !object) {
throw new BadRequestException("Set car or object to true");
}
(firstParty as any).carBodyFirstForm = { car, object };
req.blameStatus = BlameStatus.AGREED;
await this.ensureCarBodyAccidentTypeStepExists();
await this.advanceWorkflowToNext(req, WorkflowStep.CAR_BODY_ACCIDENT_TYPE);
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "CAR_BODY_ACCIDENT_TYPE_SUBMITTED",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: { car, object },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
blameStatus: req.blameStatus,
};
}
async uploadFirstPartyVideoV2(
requestId: string,
file: Express.Multer.File,
user: any,
) {
if (!file) {
throw new BadRequestException("File is required");
}
const step3 = await this.getWorkflowStep({ stepNumber: 3 });
const step3Key = step3.stepKey as WorkflowStep; // expected: FIRST_VIDEO
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
// Validate step: currentStep must match stepNumber=3 (video)
if (req.workflow.currentStep !== step3Key) {
throw new BadRequestException(
`Invalid step. Expected currentStep=${step3Key} but got ${req.workflow.currentStep}`,
);
}
const firstPartyIndex = Array.isArray(req.parties)
? req.parties.findIndex((p) => p?.role === PartyRole.FIRST)
: -1;
if (firstPartyIndex === -1) {
throw new BadRequestException("First party not found on request");
}
const firstParty = req.parties[firstPartyIndex];
const firstPartyUserId = firstParty?.person?.userId
? String(firstParty.person.userId)
: null;
const isOwner =
(firstPartyUserId && firstPartyUserId === String(user?.sub)) ||
(firstParty?.person?.phoneNumber &&
firstParty.person.phoneNumber === user?.username);
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
throw new ForbiddenException("Only first party can upload this video");
}
if (firstParty?.evidence?.videoId) {
throw new ConflictException("First party video already uploaded");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(req._id),
} as any);
if (!firstParty.evidence) {
firstParty.evidence = {} as any;
}
firstParty.evidence.videoId = String((videoDocument as any)._id);
// Advance workflow
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(step3Key)) {
completed.push(step3Key);
}
req.workflow.completedSteps = completed;
const nextStepKey = step3.nextPossibleSteps?.[0] as
| WorkflowStep
| undefined;
if (!nextStepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=3 has no nextPossibleSteps configured",
);
}
const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey });
const afterNext = nextStepDoc.nextPossibleSteps?.[0];
if (!afterNext) {
throw new InternalServerErrorException(
`Workflow step ${nextStepKey} has no nextPossibleSteps configured`,
);
}
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = afterNext as WorkflowStep;
// History
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "FIRST_VIDEO_UPLOADED",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
stepKey: step3Key,
videoId: firstParty.evidence.videoId,
fileName: file.filename,
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
videoId: firstParty.evidence.videoId,
};
}
async initialFormV2(
requestId: string,
body: AddPlateDto,
user: any,
expectedRole?: string,
) {
try {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_INITIAL_FORM &&
stepKey !== WorkflowStep.SECOND_INITIAL_FORM
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`,
);
}
// Detect which party from step
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1) {
throw new BadRequestException(`${role} party not found on request`);
}
const party = req.parties[idx];
this.assertPartyOwner(
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 &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
// Validation: driver/insurer sameness rules
if (body.driverIsInsurer === false) {
if (
body.nationalCodeOfInsurer === body.nationalCodeOfDriver ||
body.insurerLicense === body.driverLicense
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
);
}
} else if (body.driverIsInsurer === true) {
const sameNat =
body.nationalCodeOfInsurer === body.nationalCodeOfDriver;
const sameLic = body.insurerLicense === body.driverLicense;
const sameBirthday =
body.driverBirthday == null ||
String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.",
);
}
}
// ---- External inquiry 1: Tejarat block inquiry ----
let inquiryRaw: any;
let inquiryMapped: any;
const inquiryClientId = party.person?.clientId
? String(party.person.clientId)
: undefined;
const inquiryOptions = inquiryClientId
? { clientId: inquiryClientId }
: undefined;
try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry(
{
plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
},
inquiryOptions,
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.logger.log(
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
);
this.logger.log(
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) {
this.logger.error(
`[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`,
);
if (err?.response) {
this.logger.error(
`[TEJARAT] block inquiry response for request=${req._id}: status=${
err.response.status
}, data=${JSON.stringify(err.response.data)}`,
);
}
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
}
if (inquiryMapped?.Error) {
this.logger.warn(
`[TEJARAT] block inquiry error for request=${req._id}: ${JSON.stringify(
inquiryMapped.Error,
)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
inquiryMapped.Error.Message || "Inquiry returned error",
HttpStatus.BAD_REQUEST,
);
}
// ---- External inquiry 2: personal identity check (insurer/driver nationalCode + birthDate) ----
// The form sends the birthday as a Jalali date in any of these shapes:
// - number 13770624 (packed YYYYMMDD)
// - string "1377-06-24" / "1377/06/24" / "13770624"
// We forward it as-is; sandHubService.getPersonalInquiry() handles the
// Jalali → Gregorian conversion required by the external API.
const personalNationalCode =
body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
const personalBirthDate: number | string | null = (body.insurerBirthday ??
body.driverBirthday ??
null) as number | string | null;
if (
!personalNationalCode ||
personalBirthDate === null ||
personalBirthDate === undefined ||
String(personalBirthDate).trim() === ""
) {
throw new BadRequestException(
"Valid nationalCode and birthDate are required for personal inquiry.",
);
}
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
personalNationalCode,
personalBirthDate,
inquiryOptions,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
role,
true,
personalInquiry,
);
this.logger.log(
`[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(
personalInquiry,
)}`,
);
} catch (err: any) {
this.logger.error(
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
"Personal identity inquiry failed",
HttpStatus.BAD_REQUEST,
);
}
// ---- External inquiry 3: driving license check (insurerLicense + nationalCode) ----
// const licenseNationalCode = body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
// const licenseNumber = body.insurerLicense;
// if (!licenseNationalCode || !licenseNumber) {
// throw new BadRequestException(
// "nationalCode and insurerLicense are required for driving license inquiry.",
// );
// }
// try {
// const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo(
// licenseNationalCode,
// licenseNumber,
// );
// this.logger.log(
// `[SANDHUB] license inquiry success request=${req._id} nationalCode=${licenseNationalCode}: ${JSON.stringify(
// licenseInquiry,
// )}`,
// );
// } catch (err: any) {
// this.logger.error(
// `[SANDHUB] license inquiry failed request=${req._id} nationalCode=${licenseNationalCode}: ${err?.message || err}`,
// );
// throw new HttpException(
// "Driving license inquiry failed",
// HttpStatus.BAD_REQUEST,
// );
// }
// Find client by company code
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
throw new BadRequestException(
`CompanyName missing from inquiry response`,
);
}
const companyCode = inquiryMapped?.CompanyCode;
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new BadRequestException(
`CompanyCode missing or invalid in inquiry response`,
);
}
// Persist inquiry/body data into new model
if (!party.person) party.person = {} as any;
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
party.person.clientId = resolvedClientId;
party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer;
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
party.person.insurerLicense = body.insurerLicense;
party.person.driverLicense = body.driverLicense;
party.person.driverIsInsurer = body.driverIsInsurer;
party.person.isNewCar = body.isNewCar;
party.person.userNoCertificate = body.userNoCertificate;
party.person.insurerBirthday = body.insurerBirthday;
party.person.driverBirthday =
body.driverBirthday ??
(body.driverIsInsurer ? String(body.insurerBirthday) : null);
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.isNew = body.isNewCar;
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 = {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
};
if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate;
// CAR BODY EXTERNAL API INQUIRY
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
let carBodyInfo: any;
try {
carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry(
{
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
plate: body.plate,
},
resolvedClientId
? { clientId: String(resolvedClientId) }
: inquiryOptions,
);
this.recordPartyCaseInquiryStatus(req, "carBody", role, true, {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
});
} catch (err: any) {
this.logger.error(
`[TEJARAT] car body inquiry failed for request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"carBody",
role,
false,
{},
err,
);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
"Car body inquiry failed",
HttpStatus.BAD_REQUEST,
);
}
// Raw + mapped stored under vehicle
party.vehicle.inquiry = {
...party.vehicle.inquiry,
carBody: {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
},
};
// Flat insurance fields from car body inquiry
const m = carBodyInfo.mapped;
(party.insurance as any).carBodyInsurance = {
policyNumber: m.policyNumber ?? null,
companyId: m.companyId ?? null,
companyName: m.CompanyName ?? null,
insurerName: m.insurerName ?? null,
insurerNationalCode: m.insurerNationalCode ?? null,
ownerNationalCode: m.ownerNationalCode ?? null,
chassisNumber: m.ChassisNumberField ?? null,
vin: m.VinNumberField ?? null,
motorNumber: m.EngineNumberField ?? null,
vehicleGroup: m.vehicleGroupTitle ?? null,
vehicleSystem: m.vehicleSystemTitle ?? null,
startDate: m.StartDate ?? null,
endDate: m.EndDate ?? null,
issueDate: m.IssueDate ?? null,
noLossYearsCount: m.noLossYearsCount ?? null,
lossDocuments: m.lossDocuments ?? [],
hasEndorsement: m.hasEndorsement ?? null,
};
// ← THE FIX: overwrite clientId with car-body insurer's clientId
// For CAR_BODY files the relevant insurer is the one covering the car body,
// not the third-party liability insurer resolved earlier in this function.
const carBodyCompanyCode = m.companyId ?? m.CompanyCode;
const carBodyCompanyName = m.CompanyName ?? m.companyPersianName;
if (carBodyCompanyCode && carBodyCompanyName) {
const carBodyClient =
await this.clientService.findOrCreateClientByCompanyCode(
carBodyCompanyCode,
carBodyCompanyName,
);
const carBodyClientId =
(carBodyClient as any)?._id ?? (carBodyClient as any)?._doc?._id;
if (carBodyClientId) {
// Overwrite the third-party clientId set earlier in this function
party.person.clientId = carBodyClientId;
this.logger.log(
`[CAR_BODY] Overriding party clientId with car-body insurer clientId=${carBodyClientId} companyCode=${carBodyCompanyCode} for request=${req._id}`,
);
}
}
}
// Advance workflow
await this.advanceWorkflowToNext(req, stepKey);
// History
const historyEntry: any = {
type: `${stepKey}_SUBMITTED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
role,
companyCode,
companyName: clientName,
},
};
// Use findByIdAndUpdate instead of save() to avoid Mongoose VersionError
// when two parties submit their forms concurrently. Each party only writes
// its own paths, so the $set operations are non-overlapping and safe.
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: {
[`parties.${idx}.person`]: party.person,
[`parties.${idx}.vehicle`]: party.vehicle,
[`parties.${idx}.insurance`]: party.insurance,
inquiries: req.inquiries,
"workflow.completedSteps": req.workflow.completedSteps,
"workflow.currentStep": req.workflow.currentStep,
"workflow.nextStep": req.workflow.nextStep,
status: req.status,
},
$push: { history: historyEntry },
});
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
blameStatus: req.blameStatus,
status: req.status,
};
} catch (error) {
throw error;
}
}
/**
* V2 initial-form submitted with a VIN/chassis number instead of a plate.
*
* Performs:
* 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off)
* 2. Personal identity inquiry (same as the plate path)
*
* The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat`
* normaliser used by `getTejaratBlockInquiry`, so all downstream party/vehicle
* /insurance field assignments are identical.
*/
async initialFormVinV2(
requestId: string,
body: InitialFormVinDto,
user: any,
expectedRole?: string,
) {
try {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_INITIAL_FORM &&
stepKey !== WorkflowStep.SECOND_INITIAL_FORM
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`,
);
}
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1)
throw new BadRequestException(`${role} party not found on request`);
const party = req.parties[idx];
this.assertPartyOwner(
party,
user,
`Only ${role} party can submit this step`,
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
if (!party.person) party.person = {} as any;
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
// Validation: driver/insurer sameness rules (identical to plate path)
if (body.driverIsInsurer === false) {
if (
body.nationalCodeOfInsurer === body.nationalCodeOfDriver ||
body.insurerLicense === body.driverLicense
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
);
}
} else if (body.driverIsInsurer === true) {
const sameNat =
body.nationalCodeOfInsurer === body.nationalCodeOfDriver;
const sameLic = body.insurerLicense === body.driverLicense;
const sameBirthday =
body.driverBirthday == null ||
String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.",
);
}
}
// ---- External inquiry: ESG policyByChassis ----
const inquiryClientId = party.person?.clientId
? String(party.person.clientId)
: undefined;
const inquiryOptions = inquiryClientId
? { clientId: inquiryClientId }
: undefined;
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getPolicyByChassisInquiry(
body.vin,
inquiryOptions,
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.logger.log(
`[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
);
this.logger.log(
`[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) {
this.logger.error(
`[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`,
);
if (err?.response) {
this.logger.error(
`[ESG] policyByChassis response for request=${req._id}: status=${
err.response.status
}, data=${JSON.stringify(err.response.data)}`,
);
}
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST);
}
if (inquiryMapped?.Error) {
this.logger.warn(
`[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
inquiryMapped.Error.Message || "VIN inquiry returned error",
HttpStatus.BAD_REQUEST,
);
}
// ---- External inquiry 2: personal identity (same as plate path) ----
const personalNationalCode =
body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
const personalBirthDate: number | string | null = (body.insurerBirthday ??
body.driverBirthday ??
null) as number | string | null;
if (
!personalNationalCode ||
personalBirthDate === null ||
personalBirthDate === undefined ||
String(personalBirthDate).trim() === ""
) {
throw new BadRequestException(
"Valid nationalCode and birthDate are required for personal inquiry.",
);
}
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
personalNationalCode,
personalBirthDate,
inquiryOptions,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
role,
true,
personalInquiry,
);
this.logger.log(
`[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(personalInquiry)}`,
);
} catch (err: any) {
this.logger.error(
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
"Personal identity inquiry failed",
HttpStatus.BAD_REQUEST,
);
}
// Resolve insurer client from inquiry response
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
throw new BadRequestException(
"CompanyName missing from VIN inquiry response",
);
}
const companyCode = inquiryMapped?.CompanyCode;
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new BadRequestException(
"CompanyCode missing or invalid in VIN inquiry response",
);
}
// Persist party data
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
party.person.clientId = resolvedClientId;
party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer;
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
party.person.insurerLicense = body.insurerLicense;
party.person.driverLicense = body.driverLicense;
party.person.driverIsInsurer = body.driverIsInsurer;
party.person.isNewCar = body.isNewCar;
party.person.userNoCertificate = body.userNoCertificate;
party.person.insurerBirthday = body.insurerBirthday;
party.person.driverBirthday =
body.driverBirthday ??
(body.driverIsInsurer ? String(body.insurerBirthday) : null);
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.isNew = body.isNewCar;
// Store VIN as the vehicle identifier instead of plate
party.vehicle.plateId = body.vin;
party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
};
if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate;
// Advance workflow
await this.advanceWorkflowToNext(req, stepKey);
const historyEntry: any = {
type: `${stepKey}_SUBMITTED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
role,
companyCode,
companyName: clientName,
inquiryType: "VIN",
vin: body.vin,
},
};
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: {
[`parties.${idx}.person`]: party.person,
[`parties.${idx}.vehicle`]: party.vehicle,
[`parties.${idx}.insurance`]: party.insurance,
inquiries: req.inquiries,
"workflow.completedSteps": req.workflow.completedSteps,
"workflow.currentStep": req.workflow.currentStep,
"workflow.nextStep": req.workflow.nextStep,
status: req.status,
},
$push: { history: historyEntry },
});
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
blameStatus: req.blameStatus,
status: req.status,
};
} catch (error) {
throw error;
}
}
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) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_LOCATION &&
stepKey !== WorkflowStep.SECOND_LOCATION
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_LOCATION or SECOND_LOCATION but got ${stepKey}`,
);
}
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
const party = req.parties[idx];
this.assertPartyOwner(
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 &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
party.location = body as any;
await this.advanceWorkflowToNext(req, stepKey);
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: `${stepKey}_SUBMITTED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: { location: body, role },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
};
}
async uploadVoiceV2(
requestId: string,
voice: Express.Multer.File,
user: any,
expectedRole?: string,
) {
if (!voice) throw new BadRequestException("File is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_VOICE &&
stepKey !== WorkflowStep.SECOND_VOICE
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_VOICE or SECOND_VOICE but got ${stepKey}`,
);
}
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
const party = req.parties[idx];
this.assertPartyOwner(
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 &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
const voiceDoc = await this.blameVoiceDbService.create({
fileName: voice.filename,
path: voice.path,
requestId: new Types.ObjectId(req._id),
context: UploadContext.INITIAL,
} as any);
if (!party.evidence) party.evidence = {} as any;
if (!Array.isArray(party.evidence.voices)) party.evidence.voices = [];
party.evidence.voices.push(String((voiceDoc as any)._id));
await this.advanceWorkflowToNext(req, stepKey);
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: `${stepKey}_UPLOADED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
voiceId: String((voiceDoc as any)._id),
fileName: voice.filename,
role,
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
voiceId: String((voiceDoc as any)._id),
};
}
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) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_DESCRIPTION &&
stepKey !== WorkflowStep.SECOND_DESCRIPTION
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_DESCRIPTION or SECOND_DESCRIPTION but got ${stepKey}`,
);
}
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
const party = req.parties[idx];
this.assertPartyOwner(
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 &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
if (!party.statement) party.statement = {} as any;
party.statement.description = body.desc;
// THIRD_PARTY: only desc is accepted; reject CAR_BODY-only fields
if (req.type === BlameRequestType.THIRD_PARTY) {
const hasCarBodyFields =
body.accidentDate != null ||
body.accidentTime != null ||
body.weatherCondition != null ||
body.roadCondition != null ||
body.lightCondition != null;
if (hasCarBodyFields) {
throw new BadRequestException(
"THIRD_PARTY description step accepts only the desc field. Omit accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.",
);
}
} else if (req.type === BlameRequestType.CAR_BODY) {
// CAR_BODY: require desc + accident date/time and conditions
if (
body.accidentDate == null ||
body.accidentTime == null ||
body.weatherCondition == null ||
body.roadCondition == null ||
body.lightCondition == null
) {
throw new BadRequestException(
"CAR_BODY description step requires desc, accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.",
);
}
// Gate stale CAR_BODY submissions per the party's client window
// (falls back to the system default when no client is attached yet).
await this.assertCarBodyAccidentWithinWindow({
clientId: party.person?.clientId,
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
});
party.statement.accidentDate = body.accidentDate as any;
party.statement.accidentTime = body.accidentTime;
(party.statement as any).weatherCondition = body.weatherCondition;
(party.statement as any).roadCondition = body.roadCondition;
(party.statement as any).lightCondition = body.lightCondition;
}
const isSecondThirdParty =
stepKey === WorkflowStep.SECOND_DESCRIPTION &&
req.type === BlameRequestType.THIRD_PARTY;
let closedByMutualAgreement = false;
// THIRD_PARTY + AGREED: guilt/damage already implied by first-party confession — no field expert needed.
if (isSecondThirdParty && req.blameStatus === BlameStatus.AGREED) {
const built = buildMutualAgreementExpertDecision(
(req as any).toObject ? (req as any).toObject() : { ...req },
);
if (built) {
if (!(req as any).expert) (req as any).expert = {};
(req as any).expert.decision = built as any;
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(stepKey)) completed.push(stepKey);
// Same as postfield-expert reply: guilt phase is satisfied without an expert; parties must still sign.
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;
closedByMutualAgreement = true;
if (
isSecondThirdParty &&
closedByMutualAgreement &&
req.status === CaseStatus.WAITING_FOR_SIGNATURES &&
req.blameStatus === BlameStatus.AGREED &&
!this.isBlameOnBehalfActor(req, user)
) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
const firstPhone = (req.parties?.[firstIdx]?.person as any)
?.phoneNumber;
const secondPhone = (req.parties?.[secondIdx]?.person as any)
?.phoneNumber;
const baseUrl = process.env.URL;
const token = String(req._id);
const targets = [
firstPhone
? {
receptor: firstPhone,
link: `${baseUrl}/user?token=${token}`,
}
: null,
secondPhone
? {
receptor: secondPhone,
link: `${baseUrl}/user2?token=${token}`,
}
: null,
].filter(
(t): t is { receptor: string; link: string } =>
!!t?.receptor && !!t?.link,
);
for (const target of targets) {
await this.smsOrchestrationService.sendThirdPartyAgreementSignNotice(
{
receptor: target.receptor,
publicId: req.publicId,
link: target.link,
},
);
}
}
}
}
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;
}
await this.advanceWorkflowToNext(req, stepKey);
if (
stepKey === WorkflowStep.SECOND_DESCRIPTION &&
req.type === BlameRequestType.THIRD_PARTY &&
!(req as any).expert?.decision?.guiltyPartyId
) {
const built = buildMutualAgreementExpertDecision(
(req as any).toObject ? (req as any).toObject() : { ...req },
);
if (built) {
if (!(req as any).expert) (req as any).expert = {};
(req as any).expert.decision = built as any;
}
}
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: `${stepKey}_SUBMITTED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
role,
...(closedByMutualAgreement
? {
mutualAgreementWithoutExpert: true,
closedWithoutExpert: true,
awaitingSignatures: true,
}
: {}),
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
async addSecondPartyV2(
requestId: string,
phoneNumber: string,
frontendRoute: string,
user: any,
) {
try {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
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}`,
);
}
const firstPartyIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIdx === -1) {
throw new BadRequestException("First party not found");
}
const firstParty = req.parties[firstPartyIdx];
this.assertPartyOwner(
firstParty,
user,
"Only first party can invite second party",
req,
);
// Validate second party phone number is different
if (firstParty.person?.phoneNumber === phoneNumber) {
throw new BadRequestException(
"Second party phone number cannot be the same as first party",
);
}
// 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 = this.smsOrchestrationService.buildInviteLink(
frontendRoute,
requestId,
);
await this.smsOrchestrationService.sendInviteLink(
secondPartyPhone,
req.publicId,
url,
);
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
invitationUrl: url,
};
}
throw new ConflictException(
"Second party already added to this request",
);
}
// Add SECOND party with phone number
if (!Array.isArray(req.parties)) req.parties = [];
req.parties.push({
role: PartyRole.SECOND,
person: {
phoneNumber,
},
} as any);
// Update case status: now waiting for second party to complete their steps
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: phoneNumber,
},
} as any);
await (req as any).save();
// Send SMS invitation
const url = this.smsOrchestrationService.buildInviteLink(
frontendRoute,
requestId,
);
await this.smsOrchestrationService.sendInviteLink(
phoneNumber,
req.publicId,
url,
);
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
invitationUrl: url,
};
} catch (err) {
this.logger.error(
`[addSecondPartyV2] Error for request ${requestId}: ${err?.message || err}`,
);
if (err instanceof HttpException) {
throw err;
}
throw new InternalServerErrorException("Failed to add second party");
}
}
/**
* 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;
}
// If a valid OTP is already active for this number, return 200 immediately —
// no duplicate SMS, no unnecessary write, no error shown to the expert.
const canonicalPhoneAdv = normalizeIranMobile(phoneNumber) ?? phoneNumber;
const existingUserAdv = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalPhoneAdv),
);
if (existingUserAdv && isOtpExpiryActive(existingUserAdv.otpExpire)) {
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`,
};
}
// 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")) {
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`,
};
}
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();
// const createRequest = await this.requestManagementDbService.create({
// firstPartyDetails: {
// firstPartyId: user.sub,
// firstPartyPhoneNumber: user.username,
// firstPartyCertificate: null,
// },
// currentStep: StepsEnum.createRequest,
// nextStep:
// type === "THIRD_PARTY"
// ? StepsEnum.F_InitialForm
// : StepsEnum.CarBodyForm,
// requestNumber: reqNumber.rnd(),
// publicId,
// blameStatus: ReqBlameStatus.PendingForFirstParty,
// lockFile: false,
// type,
// });
// return { requestId: createRequest["_id"] };
// }
async initialFormStep(
reqBody: InitialFormDto,
user,
requestId,
): Promise<RequestManagementDtoRs> {
const foundRequest =
await this.requestManagementDbService.findOne(requestId);
if (!foundRequest) {
throw new NotFoundException("Request not found");
}
// For LINK-based expert-initiated files, verify user is the correct party
if (
foundRequest.expertInitiated &&
foundRequest.creationMethod === CreationMethod.LINK
) {
// User should already be set in firstPartyDetails or secondPartyDetails
const isFirstParty =
foundRequest.firstPartyDetails?.firstPartyId?.toString() === user.sub ||
foundRequest.firstPartyDetails?.firstPartyPhoneNumber === user.username;
const isSecondParty =
foundRequest.secondPartyDetails?.secondPartyId?.toString() ===
user.sub ||
foundRequest.secondPartyDetails?.secondPartyPhoneNumber ===
user.username;
if (!isFirstParty && !isSecondParty) {
throw new ForbiddenException(
"You are not authorized to complete this file. This file was created by an expert for specific parties.",
);
}
}
if (
foundRequest?.secondPartyDetails?.secondPartyPhoneNumber == user.username
) {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
"secondPartyDetails.secondPartyId": user.sub,
blameStatus: ReqBlameStatus.PendingForSecondParty,
"secondPartyDetails.secondPartyInitialForm": reqBody,
currentStep: StepsEnum.S_InitialForm,
nextStep: StepsEnum.S_addPlate,
$push: { steps: StepsEnum.S_InitialForm },
},
);
// Add history for expert-initiated files
if (foundRequest.expertInitiated) {
await this.addHistoryEvent(
requestId,
"SECOND_PARTY_FORM_COMPLETED",
new Types.ObjectId(user.sub),
undefined,
foundRequest.filledBy === FilledBy.EXPERT ? "expert" : "customer",
);
}
return new RequestManagementDtoRs(
foundRequest.firstPartyDetails.firstPartyId,
user.sub,
StepsEnum.S_InitialForm,
StepsEnum.S_addPlate,
StepsEnum.S_addPlate,
requestId,
);
} else if (
foundRequest?.firstPartyDetails?.firstPartyPhoneNumber == user.username ||
(foundRequest.expertInitiated &&
foundRequest.creationMethod === CreationMethod.LINK &&
foundRequest.firstPartyDetails?.firstPartyId?.toString() === user.sub)
) {
// Normal flow or link-based - set full firstPartyDetails
const updatePayload: any = {
blameStatus: ReqBlameStatus.PendingForFirstParty,
firstPartyDetails: {
firstPartyId: user.sub,
firstPartyPhoneNumber: user.username,
firstPartyInitialForm: reqBody,
},
currentStep: StepsEnum.F_InitialForm,
nextStep: StepsEnum.F_videoUpload,
$push: { steps: StepsEnum.F_InitialForm },
};
// For link-based expert-initiated files, ensure filledBy is set
if (
foundRequest.expertInitiated &&
foundRequest.creationMethod === CreationMethod.LINK
) {
updatePayload.$set = { filledBy: FilledBy.CUSTOMER };
}
const createRequestFile =
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
// Add history for expert-initiated link-based files
if (
foundRequest.expertInitiated &&
foundRequest.creationMethod === CreationMethod.LINK
) {
await this.addHistoryEvent(
requestId,
"FIRST_PARTY_FORM_COMPLETED",
new Types.ObjectId(user.sub),
undefined,
"customer",
);
}
return new RequestManagementDtoRs(
createRequestFile.firstPartyDetails.firstPartyId,
null,
StepsEnum.F_InitialForm,
StepsEnum.F_videoUpload,
"Add video",
createRequestFile._id,
);
} else {
// If none of the conditions match, throw an error
throw new ForbiddenException(
"You are not authorized to complete this form. Please ensure you are the correct party or using a valid link.",
);
}
}
async videoUploadStep(
file,
user,
requestId,
): Promise<RequestManagementDtoRs> {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Check phone number match for normal flow
if (request?.firstPartyDetails?.firstPartyPhoneNumber !== user.username) {
throw new ForbiddenException("the request doesnt belong you");
}
try {
if (
request.steps.includes(StepsEnum.F_videoUpload) ||
request.steps.includes(StepsEnum.CarBodyVideo)
)
throw new BadRequestException("video steps has exist");
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId,
});
// Determine the step to push and next step based on request type
const stepToPush =
request.type === "THIRD_PARTY"
? StepsEnum.F_videoUpload
: StepsEnum.CarBodyVideo;
const currentStepValue =
request.type === "THIRD_PARTY"
? StepsEnum.F_videoUpload
: StepsEnum.CarBodyVideo;
const nextStepValue = StepsEnum.F_addPlate;
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$push: { steps: stepToPush },
currentStep: currentStepValue,
"firstPartyDetails.firstPartyFile.firstPartyVideoId":
videoDocument["_doc"]._id,
nextStep: nextStepValue,
},
);
if (updated == undefined) return new BadGatewayException("update failed");
return new RequestManagementDtoRs(
user.sub,
null,
currentStepValue,
nextStepValue,
"please add plate",
requestId,
);
} catch (err) {
return new HttpException(err.message, err.status);
}
}
async addPlate(
sandHubReport: any,
request: any,
user: any,
body: AddPlateDto,
partyType: "firstParty" | "secondParty",
) {
const clientName = sandHubReport?.CompanyName;
const companyCode = sandHubReport?.CompanyCode;
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new HttpException("Client not found", HttpStatus.CONFLICT);
}
const partyDetails =
partyType === "firstParty" ? "firstPartyDetails" : "secondPartyDetails";
const stepsEnum =
partyType === "firstParty" ? StepsEnum.F_addPlate : StepsEnum.S_addPlate;
const currentStep =
partyType === "firstParty" ? StepsEnum.F_addPlate : StepsEnum.S_addPlate;
// Determine next step based on request type
let nextStep: StepsEnum;
if (request.type === "CAR_BODY") {
// For CAR_BODY, after plate always go to location
// (Form and video were already completed before plate)
nextStep = StepsEnum.F_addLocation;
} else {
// THIRD_PARTY or default flow
nextStep =
partyType === "firstParty"
? StepsEnum.F_addLocation
: StepsEnum.S_addLocation;
}
try {
await this.userDbService.findOneAndUpdate(
{ _id: user.sub },
{ clientKey: client["_doc"]._id },
);
const sandHubDocData = {
...sandHubReport,
plate: body.plate,
nationalCode: body.nationalCodeOfInsurer,
};
const sandHubDoc = await this.sandHubService.sandHubDocumentCreator(
user.sub,
request["_doc"]._id,
sandHubDocData,
);
if (partyType === "firstParty") {
await this.requestManagementDbService.findAndUpdate(
{ _id: request._id },
{
sanHubId: sandHubDoc["_doc"]._id,
},
);
}
// Build $set object with all field updates
const setFields: any = {
currentStep: currentStep,
nextStep: nextStep,
[`${partyDetails}.nationalCodeOfInsurer`]: body.nationalCodeOfInsurer,
[`${partyDetails}.${partyType}Plate`]: body.plate,
[`${partyDetails}.${partyType}CarDetail.carName`]:
sandHubReport.MapTypNam,
[`${partyDetails}.${partyType}CarDetail.insurerBirthday`]:
body.insurerBirthday,
[`${partyDetails}.${partyType}CarDetail.driverBirthday`]:
body.driverBirthday,
[`${partyDetails}.${partyType}CarDetail.carType`]: `${sandHubReport.UsageField} / ${sandHubReport.MapUsageName || "-"}`,
[`${partyDetails}.${partyType}CarDetail.isNewCar`]: body.isNewCar,
[`${partyDetails}.${partyType}Client.clientId`]: client["_doc"]._id,
[`${partyDetails}.${partyType}Client.clientName`]: clientName,
[`${partyDetails}.${partyType}Certificate`]: body.userNoCertificate,
[`${partyDetails}.${partyType}InsuranceDetail.insuranceId`]:
sandHubReport.LastCompanyDocumentNumber,
[`${partyDetails}.${partyType}InsuranceDetail.insuranceCompany`]:
clientName,
[`${partyDetails}.${partyType}InsuranceDetail.financialCommitmentCeiling`]:
sandHubReport.FinancialCvrCptl,
[`${partyDetails}.${partyType}InsuranceDetail.startDate`]:
sandHubReport.IssueDate,
[`${partyDetails}.${partyType}InsuranceDetail.endDate`]:
sandHubReport.EndDate,
};
// For CAR_BODY type, also fetch and persist mocked car body insurance info
if (request.type === "CAR_BODY" && partyType === "firstParty") {
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
request._id,
body.plate,
body.nationalCodeOfInsurer,
);
this.logger.log(
`[CAR_BODY] Saving car body insurance data to blame file ${request._id}:`,
JSON.stringify(carBodyInfo, null, 2),
);
// Add car body insurance fields to $set
setFields[
"firstPartyDetails.firstPartyCarBodyInsuranceDetail.policyNumber"
] = carBodyInfo.policyNumber;
setFields[
"firstPartyDetails.firstPartyCarBodyInsuranceDetail.startDate"
] = carBodyInfo.startDate;
setFields[
"firstPartyDetails.firstPartyCarBodyInsuranceDetail.endDate"
] = carBodyInfo.endDate;
setFields[
"firstPartyDetails.firstPartyCarBodyInsuranceDetail.insurerCompany"
] = carBodyInfo.insurerCompany;
setFields[
"firstPartyDetails.firstPartyCarBodyInsuranceDetail.coverages"
] = carBodyInfo.coverages;
}
// Build final update payload with proper MongoDB operators
const updatePayload: any = {
$push: { steps: stepsEnum },
$set: setFields,
};
await this.requestManagementDbService.findAndUpdate(
{ _id: request._id },
updatePayload,
);
} catch (er) {
this.logger.error(er);
throw new InternalServerErrorException(
"Failed to update request with plate details.",
);
}
const nextStepMessage = `Please add location - clientName is ${clientName}`;
return new RequestManagementDtoRs(
request?.firstPartyDetails?.firstPartyId,
request?.secondPartyDetails?.secondPartyId || null,
stepsEnum,
nextStep,
nextStepMessage,
request._id,
);
}
async addPlateToRequest(user: any, body: AddPlateDto, requestId: string) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (
(body.nationalCodeOfInsurer === body.nationalCodeOfDriver ||
body.insurerLicense === body.driverLicense) &&
body.driverIsInsurer === false
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
);
}
// Determine if the current user is the first party
// Check if nextStep is firstParty-addPlate OR if user is the first party phone number
const isFirstParty =
(request.nextStep === StepsEnum.F_addPlate ||
request.currentStep === StepsEnum.F_addPlate) &&
request.firstPartyDetails.firstPartyPhoneNumber === user.username;
if (!isFirstParty) {
const firstPartyDetails = request.firstPartyDetails as any;
if (
firstPartyDetails?.firstPartyPlate ||
firstPartyDetails?.nationalCodeOfInsurer
) {
const isSameNationalCode =
firstPartyDetails.nationalCodeOfInsurer ===
body.nationalCodeOfInsurer;
const isSamePlate =
JSON.stringify(firstPartyDetails.firstPartyPlate) ===
JSON.stringify(body.plate);
if (isSameNationalCode || isSamePlate) {
throw new ConflictException(
"The plate and national code for the second party cannot be the same as the first party.",
);
}
}
}
// 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,
});
// 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}`,
// );
// }
return await this.addPlate(
sanHubResponse,
request,
user,
body,
isFirstParty ? "firstParty" : "secondParty",
);
}
async carBodyFormStep(
reqBody: CarBodyFormDto,
user,
requestId,
): Promise<RequestManagementDtoRs> {
const foundRequest =
await this.requestManagementDbService.findOne(requestId);
if (!foundRequest) {
throw new NotFoundException("Request not found");
}
// Validate that this is a CAR_BODY type request
if (foundRequest?.type !== "CAR_BODY") {
throw new BadRequestException(
"This endpoint is only for CAR_BODY type requests",
);
}
// Validate that the user is the first party (or expert filling their own file)
if (
!foundRequest.expertInitiated &&
foundRequest?.firstPartyDetails?.firstPartyPhoneNumber !== user.username
) {
throw new ForbiddenException(
"Only the first party can complete this step",
);
}
// For CAR_BODY flow: after form, always go to video upload
// We'll assume guilty by default for car accidents (no second form needed)
const nextStep = StepsEnum.CarBodyVideo;
const createRequestFile =
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
blameStatus: ReqBlameStatus.PendingForFirstParty,
carBodyForm: {
firstPartyId: user.sub,
firstPartyPhoneNumber: user.username,
carBodyForm: reqBody,
// Assume guilty by default for car accidents
isGuilty: reqBody.car ? true : false,
},
currentStep: StepsEnum.CarBodyForm,
nextStep: nextStep,
$push: { steps: StepsEnum.CarBodyForm },
},
);
return new RequestManagementDtoRs(
createRequestFile.firstPartyDetails.firstPartyId,
null,
StepsEnum.CarBodyForm,
nextStep,
"Please upload accident video",
createRequestFile._id,
);
}
async carBodySecondForm(
reqBody: CarBodySecondForm,
user,
requestId,
): Promise<RequestManagementDtoRs> {
// This endpoint is deprecated - second form is no longer needed
// We now assume guilty by default in the first form
throw new BadRequestException(
"This endpoint is deprecated. The second form is no longer required. Users are assumed guilty by default for car accidents.",
);
}
async addDetailToRequestLocation(
userId: string,
body: LocationDto,
requestId,
user?: any, // Optional user object for expert verification
) {
const findRequest =
await this.requestManagementDbService.findOne(requestId);
if (!findRequest) throw new NotFoundException("requestId not found");
// Allow first party only
if (
findRequest.firstPartyDetails.firstPartyId == new Types.ObjectId(userId)
) {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
firstPartyLocation: body,
currentStep: StepsEnum.F_addLocation,
nextStep: StepsEnum.F_addVoice,
$push: { steps: StepsEnum.F_addLocation }, // Ensure step is saved
},
);
return new RequestManagementDtoRs(
findRequest.firstPartyDetails.firstPartyId,
findRequest?.secondPartyDetails?.secondPartyId || null,
StepsEnum.F_addLocation,
StepsEnum.F_addVoice,
"please add detail",
requestId,
);
}
if (
findRequest?.secondPartyDetails?.secondPartyId ==
new Types.ObjectId(userId)
) {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
secondPartyLocation: body,
currentStep: StepsEnum.S_addLocation,
nextStep: StepsEnum.S_addVoice,
$push: { steps: StepsEnum.S_addLocation }, // Ensure step is saved
},
);
return new RequestManagementDtoRs(
findRequest.firstPartyDetails.firstPartyId,
findRequest?.secondPartyDetails?.secondPartyId || null,
StepsEnum.S_addLocation,
StepsEnum.S_addVoice,
"please add detail",
requestId,
);
} else {
throw new BadGatewayException("parties not allowed");
}
}
async voiceUploadStep(userId: any, voice: Express.Multer.File, requestId) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("request is not found");
}
const voiceDoc = await this.blameVoiceDbService.create({
fileName: voice.filename,
path: voice.path,
requestId,
});
// Allow first party
if (request.firstPartyDetails.firstPartyId === userId.sub) {
try {
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
currentStep: StepsEnum.F_addVoice,
nextStep: StepsEnum.F_addDescription,
$push: {
"firstPartyDetails.firstPartyFile.voices": voiceDoc["_id"],
steps: StepsEnum.F_addVoice,
},
},
);
if (updated == undefined) {
return new BadGatewayException("update failed");
}
return new RequestManagementDtoRs(
request.firstPartyDetails.firstPartyId,
request?.secondPartyDetails?.secondPartyId || null,
StepsEnum.F_addVoice,
StepsEnum.F_addDescription,
"please add description",
requestId,
);
} catch (err) {
console.log(err);
throw new ExternalExceptionFilter();
}
}
if (request?.secondPartyDetails?.secondPartyId === userId.sub) {
try {
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
currentStep: StepsEnum.S_addVoice,
nextStep: StepsEnum.S_addDescription,
$push: {
"secondPartyDetails.secondPartyFiles.voices": voiceDoc["_id"],
steps: StepsEnum.S_addVoice,
},
},
);
if (updated == undefined) {
return new BadGatewayException("update failed");
}
return new RequestManagementDtoRs(
request.firstPartyDetails.firstPartyId,
request?.secondPartyDetails?.secondPartyId || null,
StepsEnum.S_addVoice,
StepsEnum.S_addDescription,
"add Description for SecondParty",
requestId,
);
} catch (err) {
console.log(err);
throw new ExternalExceptionFilter();
}
}
}
async addDescriptionToRequest(
user: any,
body: DescriptionDto,
requestId: string,
) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// --- First Party Flow ---
if (request.firstPartyDetails.firstPartyId == user.sub) {
// Build base update payload with proper MongoDB operators
const nextStepValue =
request.type === "THIRD_PARTY"
? StepsEnum.F_addSecondPartyPhoneNumber
: StepsEnum.AddSignatures;
const blameStatusValue =
request.type === "THIRD_PARTY"
? ReqBlameStatus.PendingForFirstParty
: ReqBlameStatus.WaitingForSignatures;
// Ensure firstPartyFile exists and initialize descriptions array if needed
const updatePayload: any = {
$set: {
currentStep: StepsEnum.F_addDescription,
nextStep: nextStepValue,
blameStatus: blameStatusValue,
},
$push: {
steps: StepsEnum.F_addDescription,
},
};
// Initialize firstPartyFile.descriptions if it doesn't exist, then push description
if (
!request.firstPartyDetails.firstPartyFile ||
!request.firstPartyDetails.firstPartyFile.descriptions
) {
// Initialize the descriptions array if it doesn't exist
if (!request.firstPartyDetails.firstPartyFile) {
updatePayload.$set["firstPartyDetails.firstPartyFile"] = {};
}
updatePayload.$set["firstPartyDetails.firstPartyFile.descriptions"] = [
body.desc,
];
} else {
// If descriptions array exists, push to it
updatePayload.$push["firstPartyDetails.firstPartyFile.descriptions"] =
body.desc;
}
// Add CAR_BODY specific fields if type is CAR_BODY
if (request.type === "CAR_BODY") {
if (body.accidentDate) {
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
body.accidentDate;
}
if (body.accidentTime) {
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentTime"] =
body.accidentTime;
}
if (body.weatherCondition) {
updatePayload.$set[
"firstPartyDetails.firstPartyFile.weatherCondition"
] = body.weatherCondition;
}
if (body.roadCondition) {
updatePayload.$set["firstPartyDetails.firstPartyFile.roadCondition"] =
body.roadCondition;
}
if (body.lightCondition) {
updatePayload.$set[
"firstPartyDetails.firstPartyFile.lightCondition"
] = body.lightCondition;
}
}
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
// For expert-initiated files, ensure auto-assignment and add history
if (request.expertInitiated) {
await this.ensureExpertAssignment(requestId);
await this.addHistoryEvent(
requestId,
"FIRST_PARTY_DESCRIPTION_COMPLETED",
new Types.ObjectId(user.sub),
undefined,
request.filledBy === FilledBy.EXPERT ? "expert" : "customer",
);
}
const nextStepMessage =
request.type === "CAR_BODY"
? "Please sign the document to complete your request"
: "add secondParty to request";
return new RequestManagementDtoRs(
request.firstPartyDetails.firstPartyId,
request?.secondPartyDetails?.secondPartyId || null,
StepsEnum.F_addDescription,
nextStepValue,
nextStepMessage,
requestId,
);
}
// --- Second Party Flow ---
if (request?.secondPartyDetails?.secondPartyId == user.sub) {
const firstPartyForm = request.firstPartyDetails.firstPartyInitialForm;
const secondPartyForm = request.secondPartyDetails.secondPartyInitialForm;
const shouldAutoResolve =
(firstPartyForm?.imDamaged && secondPartyForm?.imGuilty) ||
(secondPartyForm?.imDamaged && firstPartyForm?.imGuilty);
let updatePayload: any;
if (shouldAutoResolve) {
// Build the payload for the auto-resolve (agreement) flow
const guiltyPartyId = firstPartyForm.imGuilty
? request.firstPartyDetails.firstPartyId
: request.secondPartyDetails.secondPartyId;
updatePayload = {
$set: {
blameStatus: ReqBlameStatus.WaitingForSignatures,
currentStep: StepsEnum.S_addDescription,
nextStep: StepsEnum.AddSignatures,
expertSubmitReply: {
description: "منتظر امضای طرفین برای تایید توافق.",
guiltyUserId: guiltyPartyId,
submitTime: new Date(),
fields: {
/* TODO: The accidentReason field is mocked and is default option for when the shouldAutoResolve is true */
accidentReason: {
/* it should be removed and replaced with a proper mechanism to identify the accident reason */
id: 2,
label: "تغيير مسير ناگهانی",
fanavaran: 4,
},
},
},
},
$push: {
steps: StepsEnum.S_addDescription,
},
};
// Initialize secondPartyFiles.descriptions if it doesn't exist, then push description
if (
!request.secondPartyDetails?.secondPartyFiles ||
!request.secondPartyDetails.secondPartyFiles.descriptions
) {
// Initialize the descriptions array if it doesn't exist
if (!request.secondPartyDetails.secondPartyFiles) {
updatePayload.$set["secondPartyDetails.secondPartyFiles"] = {};
}
updatePayload.$set[
"secondPartyDetails.secondPartyFiles.descriptions"
] = [body.desc];
} else {
// If descriptions array exists, push to it
updatePayload.$push[
"secondPartyDetails.secondPartyFiles.descriptions"
] = body.desc;
}
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
return new RequestManagementDtoRs(
request.firstPartyDetails.firstPartyId,
user.sub,
StepsEnum.S_addDescription,
ReqBlameStatus.WaitingForSignatures,
"Agreement detected. Awaiting signatures from both parties.",
requestId,
);
} else {
// Build the payload for the standard (disagreement) flow
updatePayload = {
$set: {
blameStatus: ReqBlameStatus.UnChecked,
currentStep: StepsEnum.S_addDescription,
nextStep: StepsEnum.S_completed,
},
$push: {
steps: StepsEnum.S_addDescription,
},
};
// Initialize secondPartyFiles.descriptions if it doesn't exist, then push description
if (
!request.secondPartyDetails?.secondPartyFiles ||
!request.secondPartyDetails.secondPartyFiles.descriptions
) {
// Initialize the descriptions array if it doesn't exist
if (!request.secondPartyDetails.secondPartyFiles) {
updatePayload.$set["secondPartyDetails.secondPartyFiles"] = {};
}
updatePayload.$set[
"secondPartyDetails.secondPartyFiles.descriptions"
] = [body.desc];
} else {
// If descriptions array exists, push to it
updatePayload.$push[
"secondPartyDetails.secondPartyFiles.descriptions"
] = body.desc;
}
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
// For expert-initiated files, ensure auto-assignment and add history
if (request.expertInitiated) {
await this.ensureExpertAssignment(requestId);
await this.addHistoryEvent(
requestId,
"SECOND_PARTY_DESCRIPTION_COMPLETED",
new Types.ObjectId(user.sub),
undefined,
request.filledBy === FilledBy.EXPERT ? "expert" : "customer",
);
await this.addHistoryEvent(
requestId,
"READY_FOR_EXPERT_EVALUATION",
request.initiatedBy,
undefined,
"system",
);
}
return new RequestManagementDtoRs(
request.firstPartyDetails.firstPartyId,
user.sub,
StepsEnum.S_addSecondPartyPhoneNumber,
"expertOpinion",
"please wait... for expert reply",
requestId,
);
}
}
throw new ForbiddenException("User is not a party to this request.");
}
async addSecondPartyDetailsToRequest(
phoneNumber,
requestId,
firstPartyUser,
frontendRoutes,
req,
) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (request?.secondPartyDetails?.secondPartyId) {
throw new BadGatewayException("Second party has already been added");
}
if (request.firstPartyDetails.firstPartyPhoneNumber === phoneNumber) {
throw new BadRequestException(
"Second party phone number cannot be the same as the first party",
);
}
// Only perform the update if the second party details don't exist yet.
if (!request.secondPartyDetails) {
const updatePayload = {
blameStatus: ReqBlameStatus.PendingForSecondParty,
"secondPartyDetails.secondPartyPhoneNumber": phoneNumber,
currentStep: StepsEnum.F_completed,
nextStep: StepsEnum.S_InitialForm,
$push: {
steps: StepsEnum.F_addSecondPartyPhoneNumber,
},
};
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
}
// Send the SMS after the database update is complete.
const URL = this.smsOrchestrationService.buildInviteLink(
frontendRoutes,
requestId,
);
await this.smsOrchestrationService.sendInviteLink(
phoneNumber,
request.publicId,
URL,
);
return { url: URL };
}
async myRequests(phoneNumber) {
const res = { listOfFirstParty: [], listOfSecondParty: [] };
const blameStatuses = [
ReqBlameStatus.CheckedRequest,
ReqBlameStatus.UnChecked,
ReqBlameStatus.PendingForFirstParty,
ReqBlameStatus.PendingForSecondParty,
ReqBlameStatus.ReviewRequest,
ReqBlameStatus.CheckAgain,
ReqBlameStatus.UserPending,
ReqBlameStatus.CloseRequest,
ReqBlameStatus.WaitForUserAccept,
ReqBlameStatus.WaitingForSignatures,
ReqBlameStatus.InPersonVisit,
];
const parties = await this.requestManagementDbService.findAll({
$or: [
{
$or: [
{ "firstPartyDetails.firstPartyPhoneNumber": phoneNumber },
{ "secondPartyDetails.secondPartyPhoneNumber": phoneNumber },
],
blameStatus: { $in: blameStatuses },
},
],
});
// TODO create a DTO for Response
parties.forEach((p) => {
if (p?.secondPartyDetails?.secondPartyPhoneNumber == phoneNumber) {
res.listOfSecondParty.push({
id: p.id,
blameStatus: p.blameStatus,
requestNumber: p.requestNumber,
createdAt: new Date(p.createdAt)
.toLocaleString("fa-IR")
.split("،")[0],
timeCreatedAt: new Date(p.createdAt)
.toLocaleTimeString("fa-IR")
.split(",")[0],
type: p.type || "THIRD_PARTY", // Include type field
});
}
if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) {
res.listOfFirstParty.push({
id: p.id,
blameStatus: p.blameStatus,
requestNumber: p.requestNumber,
createdAt: new Date(p.createdAt)
.toLocaleString("fa-IR")
.split("،")[0],
timeCreatedAt: new Date(p.createdAt)
.toLocaleTimeString("fa-IR")
.split(",")[0],
type: p.type || "THIRD_PARTY", // Include type field
});
}
});
return res;
}
async getCreatableClaims(user: any) {
const userId = user.sub;
const creatableBlameFiles = await this.requestManagementDbService.findAll({
blameStatus: ReqBlameStatus.CloseRequest,
$or: [
{ "firstPartyDetails.firstPartyId": userId },
{ "secondPartyDetails.secondPartyId": userId },
],
"expertSubmitReply.guiltyUserId": { $ne: userId },
});
if (!creatableBlameFiles || creatableBlameFiles.length === 0) {
return [];
}
// Fetch existing claim files for all blame files in parallel
const claimFilesPromises = creatableBlameFiles.map((blameFile) =>
this.claimRequestManagementDbService.findOne(undefined, {
"blameFile._id": new Types.ObjectId(blameFile._id),
}),
);
const existingClaimFiles = await Promise.all(claimFilesPromises);
// Create a map of blame file ID to claim file ID for quick lookup
const blameToClaimMap = new Map();
creatableBlameFiles.forEach((blameFile, index) => {
const claimFile = existingClaimFiles[index];
if (claimFile) {
blameToClaimMap.set(blameFile._id.toString(), claimFile._id.toString());
}
});
const formattedResponse = creatableBlameFiles.map((p) => {
const claimId = blameToClaimMap.get(p._id.toString());
return {
id: p.id,
blameStatus: p.blameStatus,
requestNumber: p.requestNumber,
createdAt: new Date(p.createdAt).toLocaleString("fa-IR").split("،")[0],
timeCreatedAt: new Date(p.createdAt)
.toLocaleTimeString("fa-IR")
.split(",")[0],
...(claimId && { claimId }),
};
});
return formattedResponse;
}
async requestDetailsWithoutStatus(
request: RequestManagementModel,
user: any,
) {
const parties = this.determineUserPartyRole(request, user);
request.actorsChecker =
request.actorsChecker[request.actorsChecker.length - 1];
return { theParties: parties, requestDetail: request };
}
async requestDetailsCheckedStatus(
request: any,
user: any,
): Promise<DetailsReplyDtoRs> {
const finalReply =
request.expertSubmitReplyFinal || request.expertSubmitReply;
if (!finalReply) {
throw new NotFoundException("Expert reply not found for this request.");
}
const { guiltyUserId } = finalReply;
const isGuilty = guiltyUserId == user.sub;
let actorDetail;
for (const a of request.actorsChecker) {
if (Object.keys(a)[0].toString() === ReqBlameStatus.CheckedRequest) {
actorDetail = a;
}
}
let steps: ReqBlameStatus;
if (!finalReply.firstPartyComment) {
steps = ReqBlameStatus.PendingForSecondParty;
} else if (!finalReply.secondPartyComment) {
steps = ReqBlameStatus.PendingForFirstParty;
} else {
steps = ReqBlameStatus.WaitForUserAccept;
}
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
currentStep: steps,
$push: { steps: steps },
});
const theParties = this.determineUserPartyRole(request, user);
return new DetailsReplyDtoRs(request, {
actorDetail,
isGuilty,
steps,
theParties,
});
}
async requestDetailCloseStatus(request: RequestManagementModel) {
const finalReply =
request.expertSubmitReplyFinal || request.expertSubmitReply;
if (!finalReply) {
return { message: "اطلاعات کارشناسی برای این پرونده یافت نشد." };
}
const authoritativeReply = finalReply as any;
const bothPartiesAccepted =
authoritativeReply.firstPartyComment?.isAccept &&
authoritativeReply.secondPartyComment?.isAccept;
const wasAutoClosed =
request.autoCloseTriggerTime &&
new Date(request.autoCloseTriggerTime) < new Date();
if (bothPartiesAccepted || wasAutoClosed) {
const guiltyUserId = authoritativeReply.guiltyUserId;
if (!guiltyUserId) {
return { message: "اطلاعات طرف مقصر در پرونده یافت نشد." };
}
const { firstPartyDetails, secondPartyDetails } = request;
const isFirstPartyGuilty =
firstPartyDetails.firstPartyId.toString() === guiltyUserId.toString();
const guiltyUserDetail = isFirstPartyGuilty
? [firstPartyDetails]
: [secondPartyDetails];
const damageUserDetail = isFirstPartyGuilty
? [secondPartyDetails]
: [firstPartyDetails];
return {
requestId: request["_id"],
blameStatus: request.blameStatus,
damageUserDetail,
guiltyUserDetail,
expertResendReply: request.expertResendReply,
expertSubmitReply: request.expertSubmitReply,
expertSubmitReplyFinal: request.expertSubmitReplyFinal,
};
} else {
return { message: "یکی از طرفین پرونده را تایید نکرده است." };
}
}
async requestDetailsUserPendingStatus(
request: RequestManagementModel,
user: any,
) {
const { expertResendReply } = request;
const processPartyDetails = (partyKey: "firstParty" | "secondParty") => {
const partyReply = expertResendReply?.[partyKey];
if (!partyReply || user.sub !== partyReply[`${partyKey}Id`]) {
return null;
}
const descriptionString = partyReply[`${partyKey}Description`] || "";
const allParts = descriptionString
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const validDocTypes = Object.values(BlameDocumentType);
const requiredDocuments: BlameDocumentType[] = [];
const descriptionTextParts: string[] = [];
for (const part of allParts) {
const matchingDocType = validDocTypes.find(
(docType) =>
docType.replace(/_/g, "").toUpperCase() === part.toUpperCase(),
);
if (matchingDocType) {
requiredDocuments.push(matchingDocType);
} else {
descriptionTextParts.push(part);
}
}
const description = descriptionTextParts.join(", ");
const otherPartyKey =
partyKey === "firstParty" ? "secondParty" : "firstParty";
const otherPartyHasReplied =
!!expertResendReply?.[otherPartyKey]?.userReply;
const nextStep = otherPartyHasReplied
? "Wait For Expert Comment"
: partyKey === "firstParty"
? "Wait For Second Party Reply"
: "Wait For First Party Reply";
return {
userId: partyReply[`${partyKey}Id`],
requiredDocuments,
description,
hasReplied: partyReply.userReply != null,
hasVoice: partyReply.voice != null,
nextStep: nextStep,
};
};
const details =
processPartyDetails("firstParty") || processPartyDetails("secondParty");
return details;
}
// TODO fix requestDetailsCheckAginStatus again
async requestDetailsCheckAgainStatus(request, user) {
const { expertResendReply } = request;
const detail = [];
const addDetail = (
party,
partyId,
partyDescription,
userReply,
voice,
step1,
step2,
) => {
if (user.sub === expertResendReply?.[party]?.[`${party}Id`]) {
detail.push(expertResendReply?.[party][`${party}Id`], {
description: expertResendReply?.[party][`${party}Description`],
reply: expertResendReply[party]?.userReply !== null,
voice: !!expertResendReply[party]?.voice,
});
if (
expertResendReply?.[party][`${party}Id`] &&
!expertResendReply?.[
party === "firstParty" ? "secondParty" : "firstParty"
]?.userReply
) {
detail.push({ step: step1 });
} else {
detail.push({ step: step2 });
}
}
};
addDetail(
"firstParty",
"firstPartyId",
"firstPartyDescription",
"userReply",
"voice",
"Wait For Second Party Reply",
"Wait For Expert Comment",
);
addDetail(
"secondParty",
"secondPartyId",
"secondPartyDescription",
"userReply",
"voice",
"Wait For First Party Reply",
"Wait For Expert Comment",
);
return detail;
}
// TODO CREATE DTO FOR RESPONSE ONLY
async requestDetails(user, requestId) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) throw new NotFoundException("request not found");
const formattedRequest = {
...request,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
};
const formattingOptions: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Tehran",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
// Check if createdAt exists and then format it on our new object.
if (request.createdAt) {
formattedRequest.createdAt = new Date(request.createdAt).toLocaleString(
"fa-IR",
formattingOptions,
);
}
// Do the same for updatedAt.
if (request.updatedAt) {
formattedRequest.updatedAt = new Date(request.updatedAt).toLocaleString(
"fa-IR",
formattingOptions,
) as unknown as Date;
}
switch (request.blameStatus) {
case ReqBlameStatus.CheckedRequest:
return await this.requestDetailsCheckedStatus(request, user);
case ReqBlameStatus.UserPending:
return await this.requestDetailsUserPendingStatus(request, user);
case ReqBlameStatus.CheckAgain:
return await this.requestDetailsCheckAgainStatus(request, user);
case ReqBlameStatus.CloseRequest:
return await this.requestDetailCloseStatus(request);
default:
// Pass the full user object, not just the username
return await this.requestDetailsWithoutStatus(request, user);
}
}
private determineUserPartyRole(
request: RequestManagementModel,
user: any,
): "firstParty" | "secondParty" | null {
const { firstPartyDetails, secondPartyDetails } = request;
const userId = user.sub; // The user's unique ID
const username = user.username; // The user's phone number
// Check against both ID and phone number for robustness
if (
userId === firstPartyDetails?.firstPartyId?.toString() ||
username === firstPartyDetails?.firstPartyPhoneNumber
) {
return "firstParty";
}
if (
userId === secondPartyDetails?.secondPartyId?.toString() ||
username === secondPartyDetails?.secondPartyPhoneNumber
) {
return "secondParty";
}
return null;
}
private async recordBlameExpertActivity(args: {
expertId: string;
tenantId: string;
requestId: string;
eventType: ExpertFileActivityType;
idempotencyKey?: string;
}): Promise<void> {
if (
!Types.ObjectId.isValid(args.expertId) ||
!Types.ObjectId.isValid(args.tenantId) ||
!Types.ObjectId.isValid(args.requestId)
) {
return;
}
await this.expertFileActivityDbService.recordEvent({
expertId: args.expertId,
tenantId: args.tenantId,
fileId: args.requestId,
fileType: ExpertFileKind.BLAME,
eventType: args.eventType,
idempotencyKey: args.idempotencyKey,
});
}
private async handleDisagreement(
request: any,
originalReq: any,
isFirstPartyTheDisagreer: boolean,
): Promise<void> {
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
blameStatus: ReqBlameStatus.PartiesDisagree,
$unset: { autoCloseTriggerTime: "" },
});
this.logger.log(
`Blame request ${request._id} closed with status 'PartiesDisagree'.`,
);
const firstPartyComment = request.expertSubmitReply?.firstPartyComment;
const secondPartyComment = request.expertSubmitReply?.secondPartyComment;
let phoneNumberToNotify: string | null = null;
if (isFirstPartyTheDisagreer && secondPartyComment?.isAccept === true) {
phoneNumberToNotify =
originalReq.secondPartyDetails?.secondPartyPhoneNumber;
} else if (
!isFirstPartyTheDisagreer &&
firstPartyComment?.isAccept === true
) {
phoneNumberToNotify =
originalReq.firstPartyDetails?.firstPartyPhoneNumber;
}
// TODO SMS Sending
if (phoneNumberToNotify) {
await this.smsOrchestrationService.sendTextByKey(
phoneNumberToNotify,
"parties_disagree_notify",
"متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.",
);
}
}
private async handlePostReplyStateChanges(
updatedReq: any,
originalReq: any,
): Promise<void> {
const isCarBodyType = updatedReq.type === "CAR_BODY";
const finalReply = updatedReq.expertResendReply
? updatedReq.expertSubmitReplyFinal
: updatedReq.expertSubmitReply;
const firstAccept = finalReply?.firstPartyComment?.isAccept;
const secondAccept = finalReply?.secondPartyComment?.isAccept;
// 🟥 Step 1: Handle rejections
if (!isCarBodyType && (firstAccept === false || secondAccept === false)) {
await this.handleDisagreement(
updatedReq,
originalReq,
firstAccept === false,
);
return;
}
// 🟦 Step 2: CAR_BODY logic (one side only)
if (isCarBodyType) {
if (firstAccept) {
await this.handleFinalStateChange(updatedReq);
}
return;
}
// 🟩 Step 3: Two-party logic
if (updatedReq.blameStatus === ReqBlameStatus.WaitingForSignatures) {
if (firstAccept && secondAccept) {
await this.finalizeAutoResolvedRequest(updatedReq);
} else if (firstAccept || secondAccept) {
const message = `طرف مقابل توافق را امضا کرده است. لطفا جهت نهایی کردن پرونده، امضای خود را ثبت نمایید.`;
await this.handleOnePartyReplied(
updatedReq,
originalReq,
firstAccept,
message,
);
}
return;
}
if (firstAccept && secondAccept) {
await this.handleFinalStateChange(updatedReq);
} else if (firstAccept || secondAccept) {
const message = `طرف مقابل نظر کارشناس را قبول و امضا کرده است. در طی 24 ساعت آینده فرصت دارید تا پرونده را تکمیل کرده یا پرونده بسته خواهد شد.`;
await this.handleOnePartyReplied(
updatedReq,
originalReq,
firstAccept,
message,
);
}
}
private async finalizeAutoResolvedRequest(request: any): Promise<void> {
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
blameStatus: ReqBlameStatus.CloseRequest,
$unset: { autoCloseTriggerTime: "" },
});
this.logger.log(
`Blame request ${request._id} auto-resolved and closed successfully.`,
);
}
private buildReplyUpdate(
request: any,
userId: string,
isAccept: boolean,
signDetail: any,
): any {
const isObjection = !!request.expertResendReply;
const replyField = isObjection
? "expertSubmitReplyFinal"
: "expertSubmitReply";
// Handle cases where firstPartyId might be undefined (e.g., IN_PERSON expert-filled files)
const firstPartyId = request.firstPartyDetails?.firstPartyId;
if (!firstPartyId) {
throw new BadRequestException(
"First party ID is missing. Please ensure the file has been properly initialized.",
);
}
const isFirstParty = userId === firstPartyId.toString();
const currentPartyKey = isFirstParty
? "firstPartyComment"
: "secondPartyComment";
const otherPartyKey = isFirstParty
? "secondPartyComment"
: "firstPartyComment";
const newReplyObject = JSON.parse(
JSON.stringify(request[replyField] ?? {}),
);
const currentUserComment = {
isAccept: isAccept,
signDetail: {
fileId: signDetail._id,
fileName: signDetail.fileName,
},
};
newReplyObject[currentPartyKey] = currentUserComment;
if (!newReplyObject[otherPartyKey]) {
newReplyObject[otherPartyKey] = null;
}
const update = {
$set: {
[replyField]: newReplyObject,
},
};
return update;
}
private async handleFinalStateChange(request: any): Promise<void> {
const alreadyClosed = request.blameStatus === ReqBlameStatus.CloseRequest;
const alreadyHandled = request.isHandledStatsUpdated === true;
if (!alreadyClosed) {
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
blameStatus: ReqBlameStatus.CloseRequest,
$unset: { autoCloseTriggerTime: "" },
});
}
if (!alreadyHandled) {
// Only update expert stats if the request was locked by an expert
// (CAR_BODY requests might not have actorLocked set)
if (request.actorLocked?.actorId) {
// Convert ObjectIds to strings safely
const actorIdStr = request.actorLocked.actorId.toString
? request.actorLocked.actorId.toString()
: String(request.actorLocked.actorId);
const requestIdStr = request._id.toString
? request._id.toString()
: String(request._id);
const tenantIdStr =
request.firstPartyDetails?.firstPartyClient?.clientId?.toString?.() ||
request.secondPartyDetails?.secondPartyClient?.clientId?.toString?.() ||
"";
await this.recordBlameExpertActivity({
expertId: actorIdStr,
tenantId: tenantIdStr,
requestId: requestIdStr,
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `blame:${requestIdStr}:handled:${actorIdStr}`,
});
}
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
$set: { isHandledStatsUpdated: true },
});
}
}
private async handleOnePartyReplied(
updatedReq: any,
originalReq: any,
isFirstPartyAccepted: boolean,
message: string,
): Promise<void> {
if (updatedReq.autoCloseTriggerTime) {
return;
}
const phoneNumber = isFirstPartyAccepted
? originalReq.secondPartyDetails?.secondPartyPhoneNumber
: originalReq.firstPartyDetails?.firstPartyPhoneNumber;
if (phoneNumber) {
const key = message.includes("24 ساعت")
? "one_party_accepted_wait_24h"
: "one_party_signed_wait_signature";
await this.smsOrchestrationService.sendTextByKey(
phoneNumber,
key,
message,
);
}
// await this.autoCloseRequestService.scheduleAutoClose({
// requestId: updatedReq._id.toString(),
// runAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
// });
}
async userReply(
props,
isAccept: boolean,
file: Express.Multer.File,
): Promise<UserReplyDto> {
const { requestId, user } = props;
if (!file) throw new BadRequestException("A signature file is required.");
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) throw new NotFoundException("Request not found.");
const signDetail = await this.userSign.create({
fileName: file.filename,
userId: user.sub,
path: file.path,
requestId,
});
const updatePayload = this.buildReplyUpdate(
request,
user.sub,
isAccept,
signDetail,
);
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
const updatedReq = await this.requestManagementDbService.findOne(requestId);
await this.handlePostReplyStateChanges(updatedReq, request);
return new UserReplyDto(requestId);
}
async userResend(
files: { [key: string]: Express.Multer.File[] },
user: any,
requestId: string,
description: string,
) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
const partyKey = this.determinePartyAndValidate(request, user.sub);
const { voiceId, uploadedDocumentIds } = await this.processResendUploads(
files,
requestId,
);
const updatePayload: any = {
$set: {
[`expertResendReply.${partyKey}.userReply`]: description,
},
};
if (voiceId) {
updatePayload.$set[`expertResendReply.${partyKey}.voice`] = voiceId;
}
for (const docType in uploadedDocumentIds) {
updatePayload.$set[`expertResendReply.${partyKey}.documents.${docType}`] =
uploadedDocumentIds[docType];
}
await this.requestManagementDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
await this.updateRequestStatusIfNeeded(requestId);
return { message: "Update saved successfully." };
}
// Mocked CAR_BODY insurance inquiry replace with real external API later
private async mockCarBodyInsuranceInquiry(
requestId: string,
plate: any,
nationalCodeOfInsurer: string,
): Promise<{
policyNumber: string;
startDate: string;
endDate: string;
insurerCompany: string;
coverages: string[];
}> {
this.logger.log(
`[CAR_BODY] Mocking car body insurance inquiry for request ${requestId} (plate=${JSON.stringify(
plate,
)}, nationalCodeOfInsurer=${nationalCodeOfInsurer})`,
);
const today = new Date();
const oneYearLater = new Date(
today.getFullYear() + 1,
today.getMonth(),
today.getDate(),
);
return {
policyNumber: "CB-MOCK-123456",
startDate: today.toISOString().slice(0, 10),
endDate: oneYearLater.toISOString().slice(0, 10),
insurerCompany: "Mock Car Body Insurance Co.",
coverages: ["آتش‌سوزی", "سرقت", "بدنه کامل"],
};
}
private async processResendUploads(
files: { [key: string]: Express.Multer.File[] },
requestId: string,
) {
const uploadedDocumentIds: { [key in BlameDocumentType]?: Types.ObjectId } =
{};
let voiceId: Types.ObjectId | null = null;
for (const fieldName in files) {
const fileArray = files[fieldName];
if (fileArray && fileArray.length > 0) {
const file = fileArray[0];
if (fieldName === "voice") {
voiceId = await this.createVoiceDocument(
file,
requestId,
UploadContext.EXPERT_RESEND,
);
} else {
const documentType = fieldName as BlameDocumentType;
const docId = await this.createBlameDocument(
file,
requestId,
documentType,
);
uploadedDocumentIds[documentType] = docId;
}
}
}
return { voiceId, uploadedDocumentIds };
}
private async createBlameDocument(
file: Express.Multer.File,
requestId: string,
documentType: BlameDocumentType,
): Promise<Types.ObjectId> {
const doc = await this.blameDocumentDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
fileName: file.filename,
documentType: documentType,
});
return (doc as any)._id;
}
private determinePartyAndValidate(
request: any,
userId: string,
): "firstParty" | "secondParty" {
const firstPartyId = request?.firstPartyDetails?.firstPartyId?.toString();
const secondPartyId =
request?.secondPartyDetails?.secondPartyId?.toString();
if (userId === firstPartyId) {
if (request.expertResendReply?.firstParty?.userReply) {
throw new BadRequestException(
"You have already submitted a reply for this resend request.",
);
}
return "firstParty";
}
if (userId === secondPartyId) {
if (request.expertResendReply?.secondParty?.userReply) {
throw new BadRequestException(
"You have already submitted a reply for this resend request.",
);
}
return "secondParty";
}
throw new ForbiddenException("User is not a party to this request.");
}
private async createVoiceDocument(
file: Express.Multer.File,
requestId: string,
context: UploadContext,
): Promise<Types.ObjectId> {
const voiceDoc = await this.blameVoiceDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
fileName: file.filename,
context: context,
});
return (voiceDoc as any)._id;
}
private async updateRequestStatusIfNeeded(requestId: string): Promise<void> {
const updatedRequest =
await this.requestManagementDbService.findOne(requestId);
const reply = updatedRequest.expertResendReply;
if (!reply) {
return; // No resend initiated, nothing to do.
}
// Helper function to check if a description contains a request for documents.
const doesDescriptionRequireDocuments = (
description: string | null | undefined,
): boolean => {
if (!description) return false;
const validDocTypes = Object.values(BlameDocumentType);
const descriptionParts = description.split(",").map((p) => p.trim());
// Return true if any part of the description matches a known document type.
return descriptionParts.some((part) =>
validDocTypes.some(
(docType) =>
docType.replace(/_/g, "").toUpperCase() === part.toUpperCase(),
),
);
};
// 1. Determine which parties were actually asked to resubmit DOCUMENTS.
const requiredPartiesToReply: ("firstParty" | "secondParty")[] = [];
if (
reply.firstParty &&
doesDescriptionRequireDocuments(reply.firstParty.firstPartyDescription)
) {
requiredPartiesToReply.push("firstParty");
}
if (
reply.secondParty &&
doesDescriptionRequireDocuments(reply.secondParty.secondPartyDescription)
) {
requiredPartiesToReply.push("secondParty");
}
if (requiredPartiesToReply.length === 0) {
return;
}
const allRequiredRepliesAreIn = requiredPartiesToReply.every(
(party) => !!reply[party]?.userReply,
);
if (
allRequiredRepliesAreIn &&
updatedRequest.blameStatus !== ReqBlameStatus.CheckAgain
) {
await this.requestManagementDbService.findByIdAndUpdate(requestId, {
blameStatus: ReqBlameStatus.CheckAgain,
});
this.logger.log(
`Request ${requestId} status updated to CheckAgain as all required parties have resubmitted.`,
);
}
}
private async addedClientKey(data: { clientName: any; companyCode: number }) {
if (process.env.ADDED_AUTO_CLIENT_KEY) {
let clientExist = await this.clientService.findOne({
clientName: data.clientName,
});
if (!clientExist) {
try {
await this.clientService.addClient({
clientCode: data.companyCode,
clientName: {
persian: data.clientName,
english: null,
},
useExpertMode: "genuine",
});
} catch (err) {
console.log(err);
}
}
}
}
/**
* Verify that expert can access this file (only if they initiated it)
*/
private async verifyExpertAccess(
request: RequestManagementModel,
user: any,
): Promise<void> {
// If user is not an expert type, no verification needed (normal flow)
const expertRoles = [
RoleEnum.EXPERT,
RoleEnum.DAMAGE_EXPERT,
RoleEnum.FIELD_EXPERT,
];
if (!expertRoles.includes(user.role)) {
return;
}
// If file is not expert-initiated, experts cannot access it
if (!request.expertInitiated || !request.initiatedBy) {
throw new ForbiddenException(
"Experts can only access files they have initiated",
);
}
// Verify the expert is the one who initiated this file
if (String(request.initiatedBy) !== user.sub) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
}
/**
* Verify the current user (field expert) can access this BlameRequest (v2).
*/
private async verifyExpertAccessForBlameV2(req: any, expert: any): Promise<void> {
// FILE_REVIEWER can access V4/V5 FileMaker-sealed files.
// They must be the assigned reviewer (or the file is still open for taking).
if (expert?.role === RoleEnum.FILE_REVIEWER) {
if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) {
throw new ForbiddenException(
"FileReviewer can only access V4/V5 FileMaker files.",
);
}
if (
req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER &&
req.status !== CaseStatus.WAITING_FOR_EXPERT &&
req.status !== CaseStatus.COMPLETED
) {
throw new ForbiddenException(
"This file has not been sealed by a FileMaker yet.",
);
}
// Enforce reviewer assignment: must be assigned to them or open (no reviewer yet).
// If the file is open (no assignedFileReviewerId) we atomically claim it now —
// this handles the case where a reviewer skipped the explicit lock step and went
// straight to working on the file (e.g. accident-fields before calling the lock
// endpoint). Without this, assignedFileReviewerId is never written and the file
// disappears from the reviewer's list after the blame moves to WAITING_FOR_EXPERT.
const assignedId = req.assignedFileReviewerId
? String(req.assignedFileReviewerId)
: null;
if (assignedId && assignedId !== String(expert.sub)) {
throw new ForbiddenException(
"This file has been taken by another FileReviewer.",
);
}
if (!assignedId) {
// Atomically claim — ignore if another reviewer won the race (they would have
// been caught by the assignedId check above on their own first call).
await this.blameRequestDbService.findOneAndUpdate(
{
_id: req._id,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } },
{ new: false },
);
}
return;
}
if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
return;
}
if (req?.registrarInitiated && req?.initiatedByRegistrarId) {
if (String(req.initiatedByRegistrarId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
return;
}
throw new ForbiddenException(
"This file is not initiator-scoped or has no initiating actor",
);
}
async createRegistrarInitiatedBlame(
registrar: any,
dto: { type: "THIRD_PARTY" | "CAR_BODY" },
): Promise<{ requestId: string; publicId: string }> {
const registrarId = new Types.ObjectId(registrar.sub);
const type =
dto.type === "CAR_BODY"
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
if (!nextStep) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
const publicId = await this.publicIdService.generateRequestPublicId();
const parties: any[] = [{ role: PartyRole.FIRST, person: {} }];
if (type === BlameRequestType.THIRD_PARTY) {
parties.push({ role: PartyRole.SECOND, person: {} });
}
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties,
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
registrarInitiated: true,
initiatedByRegistrarId: registrarId,
creationMethod: CreationMethod.IN_PERSON,
filledBy: FilledBy.REGISTRAR,
} as any);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_REGISTRAR",
actor: {
actorId: registrarId,
actorName:
`${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
actorType: "registrar",
},
metadata: { creationMethod: CreationMethod.IN_PERSON, type: dto.type },
});
await (created as any).save();
return {
requestId: String((created as any)._id),
publicId: (created as any).publicId,
};
}
/**
* Helper method to add history event to a request
*/
private async addHistoryEvent(
requestId: string,
eventType: string,
actorId?: Types.ObjectId,
actorName?: string,
actorType?: string,
metadata?: any,
): Promise<void> {
const historyEvent: FileHistoryEvent = {
eventType,
actorId,
actorName,
actorType,
timestamp: new Date(),
metadata,
};
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$push: { history: historyEvent },
},
);
}
/**
* Expert creates an initial blame file (incomplete, waiting for completion)
*/
// async createExpertInitiatedFile(
// expert: any,
// dto: CreateExpertInitiatedFileDto,
// ): Promise<{ requestId: string }> {
// const reqNumber = new ShortUniqueId({ counter: 1 });
// const expertId = new Types.ObjectId(expert.sub);
// const publicId = await this.publicIdService.generateRequestPublicId();
// // For LINK method, validate phone numbers are provided
// if (dto.creationMethod === CreationMethod.LINK) {
// if (!dto.firstPartyPhoneNumber) {
// throw new BadRequestException(
// "First party phone number is required for LINK method",
// );
// }
// if (dto.type === "THIRD_PARTY" && !dto.secondPartyPhoneNumber) {
// throw new BadRequestException(
// "Second party phone number is required for LINK method with THIRD_PARTY type",
// );
// }
// if (
// dto.type === "THIRD_PARTY" &&
// dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber
// ) {
// throw new BadRequestException(
// "First and second party phone numbers must be different",
// );
// }
// }
// // For LINK method, create/get users and set their IDs
// let firstPartyUserId: Types.ObjectId | undefined;
// let secondPartyUserId: Types.ObjectId | undefined;
// let firstPartyDetails: any = {};
// let secondPartyDetails: any = {};
// if (dto.creationMethod === CreationMethod.LINK) {
// // Get or create first party user
// firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
// dto.firstPartyPhoneNumber,
// );
// firstPartyDetails = {
// firstPartyId: firstPartyUserId,
// firstPartyPhoneNumber: dto.firstPartyPhoneNumber,
// };
// // Get or create second party user for THIRD_PARTY
// if (dto.type === "THIRD_PARTY" && dto.secondPartyPhoneNumber) {
// secondPartyUserId = await this.getOrCreateUserByPhoneNumber(
// dto.secondPartyPhoneNumber,
// );
// secondPartyDetails = {
// secondPartyId: secondPartyUserId,
// secondPartyPhoneNumber: dto.secondPartyPhoneNumber,
// };
// }
// }
// const createRequest = await this.requestManagementDbService.create({
// requestNumber: reqNumber.rnd(),
// publicId,
// type: dto.type,
// expertInitiated: true,
// initiatedBy: expertId,
// creationMethod: dto.creationMethod,
// filledBy: dto.creationMethod === CreationMethod.IN_PERSON ? FilledBy.EXPERT : FilledBy.CUSTOMER,
// currentStep: StepsEnum.createRequest,
// nextStep:
// dto.type === "THIRD_PARTY"
// ? StepsEnum.F_InitialForm
// : StepsEnum.CarBodyForm,
// blameStatus: ReqBlameStatus.PendingForFirstParty,
// lockFile: false,
// history: [],
// assignedExpertId: expertId, // Auto-assign to initiating expert
// ...(dto.creationMethod === CreationMethod.LINK && {
// firstPartyDetails,
// ...(dto.type === "THIRD_PARTY" && { secondPartyDetails }),
// }),
// });
// const requestId = (createRequest as any)._id.toString();
// // Add history event
// await this.addHistoryEvent(
// requestId,
// "FILE_CREATED_BY_EXPERT",
// expertId,
// `${expert.firstName} ${expert.lastName}`,
// "expert",
// {
// creationMethod: dto.creationMethod,
// type: dto.type,
// },
// );
// return {
// requestId,
// };
// }
/**
* V2: Field expert creates an expert-initiated blame file (BlameRequest with workflow).
* LINK: creates request with parties identified by phone; expert sends link to user(s) who then fill via normal v2 flow.
* IN_PERSON: creates minimal request; expert fills everything via complete-blame-data v2.
*/
async createExpertInitiatedBlameV2(
expert: any,
dto: CreateExpertInitiatedFileDto,
): Promise<{ requestId: string; publicId: string; linkUrl?: string }> {
const expertId = new Types.ObjectId(expert.sub);
const type =
dto.type === "CAR_BODY"
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
if (!nextStep) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
const publicId = await this.publicIdService.generateRequestPublicId();
// 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: {} }];
// Before creating, clean up any orphaned IN_PERSON shell from this expert
// that never had its first party OTP verified.
if (dto.creationMethod === CreationMethod.IN_PERSON) {
await this.blameRequestDbService.deleteMany({
initiatedByFieldExpertId: expertId,
creationMethod: CreationMethod.IN_PERSON,
"workflow.currentStep": firstStep.stepKey, // still at CREATED
"parties.0.person.userId": { $exists: false }, // no verified party yet
});
}
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties,
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
expertInitiated: true,
initiatedByFieldExpertId: expertId,
creationMethod: dto.creationMethod,
filledBy:
dto.creationMethod === CreationMethod.IN_PERSON
? FilledBy.EXPERT
: FilledBy.CUSTOMER,
...(isFileMakerRole ? { isMadeByFileMaker: true } : {}),
});
const requestId = String((created as any)._id);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_FIELD_EXPERT",
actor: {
actorId: expertId,
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { creationMethod: dto.creationMethod, type: dto.type },
});
await (created as any).save();
const result: {
requestId: string;
publicId: string;
linkUrl?: string;
} = {
requestId,
publicId: (created as any).publicId,
};
if (dto.creationMethod === CreationMethod.LINK) {
const baseUrl =
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
result.linkUrl = baseUrl
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
: undefined;
}
return result;
}
/**
* 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,
dto: { phoneNumber: string },
): Promise<{
sent: 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.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
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",
);
}
// Store / update the first party's phone and bind a user account
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
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 =
normalizeIranMobile(phone) ?? 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({
type: "LINK_SENT",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { sentTo, template: "yara-field-expert-link" },
});
await (req as any).save();
return {
sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent),
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.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
await 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[] = [];
// Helper: send OTP or silently succeed when a valid OTP already exists.
const sendOrSkipIfActive = async (phone: string): Promise<void> => {
const canonical = normalizeIranMobile(phone) ?? phone;
const existing = await this.userDbService.findOne(
buildUserLookupByPhone(canonical),
);
if (existing && isOtpExpiryActive(existing.otpExpire)) {
sent.push(phone); // already has a live OTP — treat as sent
return;
}
try {
await this.userAuthService.sendOtpRequest(phone);
sent.push(phone);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
sent.push(phone); // race: sendOtpRequest found it active too — treat as sent
return;
}
throw e;
}
};
await sendOrSkipIfActive(dto.firstPartyPhoneNumber);
if (dto.secondPartyPhoneNumber) {
await sendOrSkipIfActive(dto.secondPartyPhoneNumber);
}
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.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
const now = Date.now();
const verifyOne = async (
phone: string,
otp: string,
): Promise<Types.ObjectId> => {
const user = await this.userDbService.findOne(
buildUserLookupByPhone(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 =
normalizeIranMobile(dto.firstPartyPhoneNumber) ??
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 =
normalizeIranMobile(dto.secondPartyPhoneNumber) ??
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:
expert?.role === RoleEnum.REGISTRAR ? "registrar" : "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.",
};
}
/**
* 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.",
);
}
await this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required");
// --- Cooldown guard (in-memory history, catches already-persisted sends) ---
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
const recentlySentToPhone = (req.history || [])
.slice()
.reverse()
.find(
(event: any) =>
event?.type === "PARTY_OTP_SENT" &&
event?.metadata?.phoneNumber === phone &&
event?.timestamp &&
new Date(event.timestamp) > twoMinutesAgo,
);
if (recentlySentToPhone) {
return {
sent: true,
message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`,
};
}
// --- DB-level cooldown guard (catches burst/concurrent requests before history is saved) ---
// Read the user's otpExpire directly from the users collection. If a live
// OTP already exists return 200 immediately — no duplicate SMS, no DB writes.
const canonicalPhone = normalizeIranMobile(phone) ?? phone;
const existingUser = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalPhone),
);
if (existingUser && isOtpExpiryActive(existingUser.otpExpire)) {
return {
sent: true,
message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`,
};
}
try {
await this.userAuthService.sendOtpRequest(phone);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
return {
sent: true,
message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`,
};
}
throw e;
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "PARTY_OTP_SENT",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { phoneNumber: phone },
} as any);
await (req as any).save();
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.",
);
}
await 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(
buildUserLookupByPhone(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;
const canonicalPhone = normalizeIranMobile(phone) ?? phone;
const firstPartyUserId =
firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : undefined;
if (
role === PartyRole.SECOND &&
firstPartyUserId != null &&
String(firstPartyUserId) === String(userId)
) {
throw new BadRequestException(
"The damaged party must verify OTP with a different phone number than the guilty party. " +
"Both parties cannot be the same user account.",
);
}
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 = canonicalPhone;
} else {
const firstPhone =
firstIdx !== -1
? (req.parties[firstIdx]?.person as any)?.phoneNumber
: undefined;
const firstVariants = firstPhone
? iranMobileLookupVariants(firstPhone)
: [];
if (
firstPhone &&
(firstPhone === phone ||
firstVariants.includes(phone) ||
firstVariants.includes(canonicalPhone))
) {
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: canonicalPhone },
} 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 = canonicalPhone;
}
}
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: "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.
*/
async getMyExpertInitiatedFiles(expert: any): Promise<any[]> {
const expertId = new Types.ObjectId(expert.sub);
const files = await this.requestManagementDbService.findAll({
expertInitiated: true,
initiatedBy: expertId,
});
return (files || []).map((f) => ({
_id: f._id,
requestNumber: f.requestNumber,
type: f.type,
creationMethod: f.creationMethod,
blameStatus: f.blameStatus,
currentStep: f.currentStep,
nextStep: f.nextStep,
createdAt: f.createdAt,
steps: f.steps,
}));
}
/**
* V2: List expert-initiated blame files (BlameRequest) for the current field expert.
* Only files where initiatedByFieldExpertId === current expert are returned.
*/
async getMyExpertInitiatedFilesV2(expert: any): Promise<any[]> {
const expertId = new Types.ObjectId(expert.sub);
const files = await this.blameRequestDbService.find({
blameStatus: { $ne: BlameStatus.UNKNOWN },
$or: [
{ expertInitiated: true, initiatedByFieldExpertId: expertId },
{ registrarInitiated: true, initiatedByRegistrarId: expertId },
],
});
return (files || []).map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
creationMethod: f.creationMethod,
filledBy: f.filledBy,
status: f.status,
blameStatus: f.blameStatus,
workflow: f.workflow,
partiesCount: Array.isArray(f.parties) ? f.parties.length : 0,
createdAt: f.createdAt,
}));
}
async getAllBlameRequestsV2(
user: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
try {
const linkedUserIds =
user?.role === RoleEnum.USER || !user?.role
? await resolveLinkedUserIdStrings(this.userDbService, user)
: [];
const orConditions = buildBlamePartyAccessOrConditions(
user,
linkedUserIds,
);
if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) {
orConditions.push({
expertInitiated: true,
initiatedByFieldExpertId: new Types.ObjectId(user.sub),
});
} else if (user?.role === RoleEnum.REGISTRAR && user?.sub) {
orConditions.push({
registrarInitiated: true,
initiatedByRegistrarId: new Types.ObjectId(user.sub),
});
}
// Snapshot-party fallback: find blames via claims where this user appears
// in claim.snapshot.parties (the most reliable path for IN_PERSON flows).
const idVariants = collectUserIdVariants(
...(linkedUserIds.length > 0
? linkedUserIds
: [user?.sub].filter(Boolean)),
);
if (idVariants.length > 0) {
const snapshotFilter =
idVariants.length === 1
? { "snapshot.parties.person.userId": idVariants[0] }
: { "snapshot.parties.person.userId": { $in: idVariants } };
const linkedClaims = (await this.claimCaseDbService.find(
snapshotFilter as any,
{ lean: true, select: "blameRequestId owner.userId" },
)) as any[];
for (const c of linkedClaims) {
if (!c.blameRequestId) continue;
// Exclude if this user IS the owner (guilty party) — they already show
// up via the claim-owner route; we only want the damaged party here.
const ownerStr = c.owner?.userId ? String(c.owner.userId) : null;
const isOwner = ownerStr
? idVariants.some((id) => String(id) === ownerStr)
: false;
if (isOwner) continue;
orConditions.push({ _id: c.blameRequestId });
}
}
if (orConditions.length === 0) {
return { list: [], total: 0 };
}
const requests = await this.blameRequestDbService.find(
orConditions.length === 1
? (orConditions[0] as any)
: ({ $or: orConditions } as any),
{
select:
"publicId requestNo type status blameStatus createdAt updatedAt parties expertInitiated registrarInitiated initiatedByFieldExpertId initiatedByRegistrarId creationMethod",
},
);
const enriched = requests.map((req: any) => {
const isInitiator =
(user?.role === RoleEnum.FIELD_EXPERT &&
req.expertInitiated &&
String(req.initiatedByFieldExpertId) === String(user.sub)) ||
(user?.role === RoleEnum.REGISTRAR &&
req.registrarInitiated &&
String(req.initiatedByRegistrarId) === String(user.sub));
const party = req.parties?.find((p: any) =>
partyPersonMatchesUser(p?.person, user, linkedUserIds),
);
const obj = req.toObject();
delete obj.parties;
return {
...obj,
userSide: party?.role ?? null,
initiatedByMe: isInitiator,
};
});
const paged = applyListQueryV2(
enriched,
{
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
requestNo: (r) =>
String((r as { requestNo?: string }).requestNo ?? ""),
status: (r) => String((r as { status?: string }).status ?? ""),
searchExtras: (r) => {
const row = r as {
blameStatus?: string;
type?: string;
userSide?: string;
};
return [
row.blameStatus,
row.type,
row.userSide,
String((r as { _id?: unknown })._id ?? ""),
].filter(Boolean) as string[];
},
},
query,
);
return {
list: paged.list,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
};
} catch (err) {
this.logger.error("Error: ", err);
throw new InternalServerErrorException("Something Went Wrong");
}
}
/**
* V2: Hint for UX — damaged user should open create-claim when blame is done and no claim exists.
* Mirrors eligibility rules in createClaimFromBlameV2 (without throwing).
*/
private async computeClaimCreationHintForViewer(
blame: any,
user: any,
): Promise<{ hasClaim: boolean; shouldGuideToCreateClaim: boolean }> {
const none = { hasClaim: false, shouldGuideToCreateClaim: false };
if (!user?.sub || !Types.ObjectId.isValid(String(user.sub))) {
return none;
}
const currentUserId = String(user.sub);
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: blame._id,
});
const hasClaim = !!existingClaim;
if (hasClaim) {
return { hasClaim: true, shouldGuideToCreateClaim: false };
}
if (blame.status !== CaseStatus.COMPLETED) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
const parties = blame.parties || [];
if (blame.type === BlameRequestType.CAR_BODY) {
const first = parties.find(
(p: any) => p?.role === PartyRole.FIRST || p?.role === "FIRST",
);
const damagedId = first?.person?.userId
? String(first.person.userId)
: null;
if (!damagedId || damagedId !== currentUserId) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
return { hasClaim: false, shouldGuideToCreateClaim: true };
}
if (blame.type === BlameRequestType.THIRD_PARTY) {
const guiltyRaw = blame.expert?.decision?.guiltyPartyId;
if (!guiltyRaw) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
const guiltyId = String(guiltyRaw);
if (currentUserId === guiltyId) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
const userParty = parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === currentUserId,
);
if (!userParty) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
if (parties.length < 2) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
const allSigned = parties.every(
(p: any) => p.confirmation !== undefined && p.confirmation !== null,
);
const allAccepted = parties.every(
(p: any) => p.confirmation?.accepted === true,
);
if (!allSigned || !allAccepted) {
return { hasClaim: false, shouldGuideToCreateClaim: false };
}
return { hasClaim: false, shouldGuideToCreateClaim: true };
}
return none;
}
/** Party payload for user blame detail: no national codes, licenses, phone, birthdays, or vehicle inquiry blobs. */
private async sanitizePartyForBlameUserView(
party: any,
): Promise<Record<string, unknown>> {
if (!party || typeof party !== "object") {
return {};
}
const person = party.person;
const safePerson = person
? {
userId: person.userId != null ? String(person.userId) : undefined,
fullName: person.fullName,
clientId:
person.clientId != null ? String(person.clientId) : undefined,
}
: undefined;
const vehicle = party.vehicle
? {
plateId: party.vehicle.plateId,
name: party.vehicle.name,
model: party.vehicle.model,
type: party.vehicle.type,
isNew: party.vehicle.isNew,
}
: undefined;
const evidenceRaw = party.evidence as Record<string, unknown> | undefined;
const evidence: Record<string, unknown> | undefined = evidenceRaw
? { ...evidenceRaw }
: undefined;
if (evidence) {
if (party.role === PartyRole.FIRST && evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) {
evidence.videoUrl = buildFileLink(videoDoc.path);
}
}
delete evidence.videoId;
const voiceIds = evidence.voices;
if (Array.isArray(voiceIds)) {
const voiceUrls: string[] = [];
for (const voiceId of voiceIds) {
const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) {
voiceUrls.push(buildFileLink(voiceDoc.path));
}
}
evidence.voiceUrls = voiceUrls;
}
delete evidence.voices;
}
return {
role: party.role,
person: safePerson,
carBodyFirstForm: party.carBodyFirstForm,
location: party.location,
vehicle,
insurance: party.insurance,
statement: party.statement,
evidence,
confirmation: party.confirmation,
};
}
private async resolveBlameExpertDisplayNameFromPlain(
expert: Record<string, unknown>,
): Promise<string | undefined> {
const decision = expert.decision as Record<string, unknown> | undefined;
const decided = decision?.decidedByExpertId;
const assigned = expert.assignedExpertId;
const raw = decided ?? assigned;
if (raw == null || raw === "") return undefined;
const sid = String(raw);
if (!Types.ObjectId.isValid(sid)) return undefined;
const doc = await this.expertDbService.findOne({
_id: new Types.ObjectId(sid),
});
if (!doc) return undefined;
return `${doc.firstName || ""} ${doc.lastName || ""}`.trim() || undefined;
}
/** Expert subdocument for user view: string ids, trimmed snapshot (name only), plus `expertName`. */
private async buildExpertForBlameUserView(
expertRaw: any,
): Promise<Record<string, unknown> | undefined> {
if (!expertRaw || typeof expertRaw !== "object") return undefined;
const out = JSON.parse(JSON.stringify(expertRaw)) as Record<
string,
unknown
>;
const decision = out.decision as Record<string, unknown> | undefined;
let expertName: string | undefined;
if (decision) {
if (decision.guiltyPartyId != null) {
decision.guiltyPartyId = String(decision.guiltyPartyId);
}
if (decision.decidedByExpertId != null) {
decision.decidedByExpertId = String(decision.decidedByExpertId);
}
const snap = decision.expertProfileSnapshot as
| Record<string, unknown>
| undefined;
if (snap) {
expertName =
`${snap.firstName ?? ""} ${snap.lastName ?? ""}`.trim() || undefined;
decision.expertProfileSnapshot = {
firstName: snap.firstName,
lastName: snap.lastName,
};
}
}
if (out.assignedExpertId != null) {
out.assignedExpertId = String(out.assignedExpertId);
}
if (!expertName) {
expertName = await this.resolveBlameExpertDisplayNameFromPlain(out);
}
return { ...out, expertName };
}
/**
* V2: Get one blame request by id. Access allowed if current user is a party (by userId or phone)
* or the initiating field expert. For expert-initiated LINK, party access by phone is sufficient.
*/
async getBlameRequestV2(requestId: string, user: any): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
const linkedUserIds =
user?.role === RoleEnum.USER || !user?.role
? await resolveLinkedUserIdStrings(this.userDbService, user)
: [];
const isFieldExpertOwner =
req.expertInitiated &&
req.initiatedByFieldExpertId &&
String(req.initiatedByFieldExpertId) === String(user?.sub);
const isRegistrarOwner =
req.registrarInitiated &&
req.initiatedByRegistrarId &&
String(req.initiatedByRegistrarId) === String(user?.sub);
const isParty =
Array.isArray(req.parties) &&
req.parties.some((p: any) =>
partyPersonMatchesUser(p?.person, user, linkedUserIds),
);
if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) {
throw new ForbiddenException("You do not have access to this request");
}
// Optionally bind userId to party when user opens via LINK (phone match, no userId yet)
if (
!isFieldExpertOwner &&
!isRegistrarOwner &&
user?.sub &&
Types.ObjectId.isValid(user.sub)
) {
let updated = false;
const phoneVariants = iranMobileLookupVariants(user?.username);
for (const p of req.parties || []) {
const person = p?.person;
if (!person) continue;
const phoneMatch =
person.phoneNumber &&
phoneVariants.some(
(v) =>
v === person.phoneNumber ||
v === normalizeIranMobile(person.phoneNumber),
);
if (phoneMatch && !person?.userId) {
p.person = p.person || {};
(p.person as any).userId = new Types.ObjectId(user.sub);
if (user?.username) {
(p.person as any).phoneNumber =
normalizeIranMobile(user.username) ?? user.username;
}
updated = true;
}
}
if (updated) {
await (req as any).save();
}
}
const claimCreation = await this.computeClaimCreationHintForViewer(
req,
user,
);
const plain =
typeof (req as any).toObject === "function"
? (req as any).toObject({ versionKey: false })
: { ...(req as any) };
const allParties = Array.isArray(plain.parties) ? plain.parties : [];
const visibleParties =
isFieldExpertOwner || isRegistrarOwner
? allParties
: allParties.filter((p: any) =>
partyPersonMatchesUser(p?.person, user, linkedUserIds),
);
const parties = await Promise.all(
visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)),
);
const expert = await this.buildExpertForBlameUserView(plain.expert);
return {
requestNo: plain.requestNo,
publicId: plain.publicId,
type: plain.type,
status: plain.status,
blameStatus: plain.blameStatus,
workflow: plain.workflow,
parties,
expert,
carBodyInsuranceDetail: plain.carBodyInsuranceDetail,
claimCreation,
};
}
/**
* Single endpoint handler: complete all blame-needed data.
* Routes to THIRD_PARTY or CAR_BODY completion based on request type.
*/
async completeBlameData(
expert: any,
requestId: string,
formData: any,
): Promise<RequestManagementDtoRs> {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
await this.verifyExpertAccess(request, expert);
if (request.type === "THIRD_PARTY") {
return this.expertCompleteThirdPartyForm(expert, requestId, formData);
}
if (request.type === "CAR_BODY") {
return this.expertCompleteCarBodyForm(expert, requestId, formData);
}
throw new BadRequestException(
"Unknown file type. Must be THIRD_PARTY or CAR_BODY.",
);
}
/**
* V2: Expert completes blame data for IN_PERSON BlameRequest.
* Delegates to expertCompleteThirdPartyFormV2 or expertCompleteCarBodyFormV2.
*/
async expertCompleteBlameDataV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
throw new NotFoundException("Request not found");
}
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON BlameRequest files.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
if (req.type === BlameRequestType.THIRD_PARTY) {
return this.expertCompleteThirdPartyFormV2(expert, requestId, formData);
}
if (req.type === BlameRequestType.CAR_BODY) {
return this.expertCompleteCarBodyFormV2(expert, requestId, formData);
}
throw new BadRequestException(
"Unknown file type. Must be THIRD_PARTY or CAR_BODY.",
);
}
/**
* V2: Expert completes IN_PERSON CAR_BODY BlameRequest in one go.
*/
async expertCompleteCarBodyFormV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"Only expert-initiated IN_PERSON CAR_BODY files.",
);
}
if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException("File type is not CAR_BODY.");
}
await this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.carBodyForm) {
throw new BadRequestException("carBodyForm is required.");
}
if (!formData?.expertDescription?.desc) {
throw new BadRequestException("expertDescription.desc is required.");
}
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
const firstPartyPlate = formData.firstPartyPlate;
let sandHubReport: any;
try {
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: firstPartyPlate.plate,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
});
sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
PartyRole.FIRST,
true,
sandHubReport,
);
} catch (e) {
this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e);
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
PartyRole.FIRST,
false,
{},
e,
);
await (req as any).save();
throw new InternalServerErrorException(
"Failed to process plate information.",
);
}
const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
const client = companyCode
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: await this.clientService.findOne({ clientName });
if (!client) {
throw new NotFoundException(
`Client not found for company: ${clientName}`,
);
}
// Gate stale CAR_BODY submissions using the resolved client's window
// (V2 expert-initiated path always supplies accidentDate/accidentTime
// via formData.expertDescription).
if (formData?.expertDescription?.accidentDate) {
await this.assertCarBodyAccidentWithinWindow({
clientId: (client as any)?._id,
accidentDate: formData.expertDescription.accidentDate,
accidentTime: formData.expertDescription.accidentTime,
});
}
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
requestId,
firstPartyPlate.plate,
firstPartyPlate.nationalCodeOfInsurer,
);
this.recordPartyCaseInquiryStatus(
req,
"carBody",
PartyRole.FIRST,
true,
carBodyInfo,
);
const firstParty: any = {
role: PartyRole.FIRST,
person: {
userId: firstPartyUserId,
phoneNumber:
normalizeIranMobile(formData.firstPartyPhoneNumber) ??
formData.firstPartyPhoneNumber,
clientId: (client as any)?._id ?? (client as any)?._doc?._id,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
insurerLicense: firstPartyPlate.insurerLicense,
driverLicense: firstPartyPlate.driverLicense,
driverIsInsurer: firstPartyPlate.driverIsInsurer,
isNewCar: firstPartyPlate.isNewCar,
userNoCertificate: firstPartyPlate.userNoCertificate,
insurerBirthday: firstPartyPlate.insurerBirthday,
driverBirthday: firstPartyPlate.driverBirthday,
},
vehicle: {
plateId:
this.plateToPlateIdString(firstPartyPlate.plate) ||
(firstPartyPlate as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNew: firstPartyPlate.isNewCar,
inquiry: sandHubReport,
},
insurance: {
policyNumber:
sandHubReport?.LastCompanyDocumentNumber ||
sandHubReport?.DocumentNumber,
company: clientName,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
financialCeiling:
sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
carBodyInsurance: {
policyNumber: carBodyInfo.policyNumber,
startDate: carBodyInfo.startDate,
endDate: carBodyInfo.endDate,
insurerCompany: carBodyInfo.insurerCompany,
coverages: carBodyInfo.coverages,
},
},
statement: {
description: formData.expertDescription?.desc,
accidentDate: formData.expertDescription?.accidentDate,
accidentTime: formData.expertDescription?.accidentTime,
weatherCondition: formData.expertDescription?.weatherCondition,
roadCondition: formData.expertDescription?.roadCondition,
lightCondition: formData.expertDescription?.lightCondition,
},
location: req.parties?.[0]?.location,
carBodyFirstForm: {
car: !!formData.carBodyForm.car,
object: !!formData.carBodyForm.object,
},
evidence: req.parties?.[0]?.evidence
? { ...req.parties[0].evidence }
: {},
};
req.parties = [firstParty];
req.workflow = {
currentStep: WorkflowStep.FIRST_LOCATION as any,
nextStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
completedSteps: [
WorkflowStep.CREATED,
WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
WorkflowStep.FIRST_VIDEO,
WorkflowStep.FIRST_INITIAL_FORM,
WorkflowStep.FIRST_VOICE,
WorkflowStep.FIRST_DESCRIPTION,
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.OPEN;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_COMPLETED_CAR_BODY_FORM_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
/**
* V2: Expert completes IN_PERSON THIRD_PARTY BlameRequest in one go.
*/
async expertCompleteThirdPartyFormV2(
expert: any,
requestId: string,
formData: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
!req.expertInitiated ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"Only expert-initiated IN_PERSON THIRD_PARTY files.",
);
}
if (req.type !== BlameRequestType.THIRD_PARTY) {
throw new BadRequestException("File type is not THIRD_PARTY.");
}
await this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
throw new BadRequestException(
"secondParty and guiltyPartyPhoneNumber are required.",
);
}
if (!formData?.expertDescription?.desc) {
throw new BadRequestException("expertDescription.desc is required.");
}
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
const secondPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.secondParty.phoneNumber,
);
const buildPartyFromForm = async (
phoneNumber: string,
userId: Types.ObjectId,
initialForm: any,
plateDto: any,
desc: string,
role: PartyRole,
) => {
let sandHubResponse: any;
try {
sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: plateDto.plate,
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
});
} catch (err: any) {
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await (req as any).save();
throw err;
}
const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any;
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
true,
sandHubReport,
);
const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
const client = companyCode
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: await this.clientService.findOne({ clientName });
if (!client) {
throw new NotFoundException(
`Client not found for company: ${clientName}`,
);
}
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
return {
role,
person: {
userId,
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
...(resolvedClientId ? { clientId: resolvedClientId } : {}),
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
insurerLicense: plateDto.insurerLicense,
driverLicense: plateDto.driverLicense,
driverIsInsurer: plateDto.driverIsInsurer,
isNewCar: plateDto.isNewCar,
userNoCertificate: plateDto.userNoCertificate,
insurerBirthday: plateDto.insurerBirthday,
driverBirthday: plateDto.driverBirthday,
},
vehicle: {
plateId:
this.plateToPlateIdString(plateDto.plate) ||
(plateDto as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNew: plateDto.isNewCar,
inquiry: sandHubReport,
},
insurance: {
policyNumber:
sandHubReport?.LastCompanyDocumentNumber ||
sandHubReport?.DocumentNumber,
company: clientName,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
financialCeiling:
sandHubReport?.FinancialCvrCptl ||
sandHubReport?.FinancialCommitment,
},
statement: { description: desc },
location: undefined,
evidence: {},
};
};
const firstParty = await buildPartyFromForm(
formData.firstPartyPhoneNumber,
firstPartyUserId,
formData.firstPartyInitialForm,
formData.firstPartyPlate,
formData.expertDescription?.desc || "",
PartyRole.FIRST,
);
const secondParty = await buildPartyFromForm(
formData.secondParty.phoneNumber,
secondPartyUserId,
formData.secondParty.initialForm,
formData.secondParty.plate,
formData.expertDescription?.desc || "",
PartyRole.SECOND,
);
req.parties = [firstParty, secondParty];
const guiltyPartyId =
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber
? String(firstPartyUserId)
: String(secondPartyUserId);
const damagedPhone =
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber
? formData.secondParty.phoneNumber
: formData.firstPartyPhoneNumber;
if (!req.expert) req.expert = {} as any;
req.expert.decision = {
guiltyPartyId: new Types.ObjectId(guiltyPartyId),
description: `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${formData.guiltyPartyPhoneNumber} مقصر و کاربر ${damagedPhone} زیان دیده می باشد.`,
decidedAt: new Date(),
decidedByExpertId: new Types.ObjectId(expert.sub),
fields: { ...MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS },
} as any;
req.workflow = {
currentStep: WorkflowStep.FIRST_LOCATION as any,
nextStep: WorkflowStep.SECOND_LOCATION as any,
completedSteps: [
WorkflowStep.CREATED,
WorkflowStep.FIRST_BLAME_CONFESSION,
WorkflowStep.FIRST_VIDEO,
WorkflowStep.FIRST_INITIAL_FORM,
WorkflowStep.FIRST_VOICE,
WorkflowStep.FIRST_DESCRIPTION,
WorkflowStep.SECOND_INITIAL_FORM,
WorkflowStep.SECOND_VOICE,
WorkflowStep.SECOND_DESCRIPTION,
].filter((s) => s),
locked: false,
};
req.status = CaseStatus.OPEN;
req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_COMPLETED_THIRD_PARTY_FORM_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { guiltyPartyPhoneNumber: formData.guiltyPartyPhoneNumber },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
/**
* V2: Expert submits location(s) for expert-initiated IN_PERSON blame.
* This is separated from complete-blame-data to support dedicated location UI step.
*/
async expertAddLocationsForBlameV2(
expert: any,
requestId: string,
dto: { location: LocationDto },
): Promise<any> {
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.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
if (!dto?.location) {
throw new BadRequestException("location is required");
}
req.parties[firstIdx].location = dto.location as any;
const completed = Array.isArray(req.workflow?.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) {
completed.push(WorkflowStep.FIRST_LOCATION as any);
}
req.workflow = {
...(req.workflow || {}),
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
nextStep: undefined,
completedSteps: completed,
locked: false,
} as any;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_ADDED_LOCATIONS_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType:
expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
hasLocation: true,
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
status: req.status,
};
}
/**
* V2: Expert uploads video for expert-initiated BlameRequest (first party).
*/
async expertUploadVideoForBlameV2(
expert: any,
requestId: string,
file: Express.Multer.File,
): Promise<any> {
if (!file) throw new BadRequestException("Video file is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated && !req.registrarInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Video already uploaded for this file");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
await (req as any).save();
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_UPLOADED_VIDEO_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { videoId: firstParty.evidence.videoId },
} as any);
await (req as any).save();
return { requestId: req._id, videoId: firstParty.evidence.videoId };
}
/**
* V2: Expert uploads voice for expert-initiated BlameRequest (first party).
*/
async expertUploadVoiceForBlameV2(
expert: any,
requestId: string,
voiceFile: Express.Multer.File,
): Promise<any> {
if (!voiceFile) throw new BadRequestException("Voice file is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
if (
Array.isArray(firstParty?.evidence?.voices) &&
firstParty.evidence.voices.length > 0
) {
throw new ConflictException("Voice already uploaded for this file");
}
const voiceDocument = await this.blameVoiceDbService.create({
fileName: voiceFile.filename,
path: voiceFile.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
if (!Array.isArray(firstParty.evidence.voices))
firstParty.evidence.voices = [];
firstParty.evidence.voices.push(String((voiceDocument as any)._id));
await (req as any).save();
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_UPLOADED_VOICE_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {},
} as any);
await (req as any).save();
return { requestId: req._id };
}
/**
* V2: Expert 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.",
);
}
await 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.
*/
async expertAddAccidentFieldsForBlameV2(
expert: any,
requestId: string,
fields: any,
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated && !req.registrarInitiated) {
throw new BadRequestException(
"This endpoint is only for expert- or registrar-initiated files.",
);
}
await this.verifyExpertAccessForBlameV2(req, expert);
// In the V2 IN_PERSON mirror flow guilt is resolved automatically during
// addDescriptionV2 — accident-fields must only be called once the file has
// reached WAITING_FOR_SIGNATURES (both parties' narrative steps are done).
// Calling it earlier would overwrite the workflow and skip the sign step.
if (
req.creationMethod === CreationMethod.IN_PERSON &&
req.status !== CaseStatus.WAITING_FOR_SIGNATURES &&
req.status !== CaseStatus.COMPLETED
) {
throw new BadRequestException(
`accident-fields can only be submitted after both parties have completed their narrative steps (current status: ${req.status}). Make sure both parties have gone through location, voice, and description before calling this endpoint.`,
);
}
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, workflow: req.workflow, status: req.status };
}
/**
* Expert completes all information for IN_PERSON THIRD_PARTY file at once
* This replaces the step-by-step flow for experts filling files on-site
*/
async expertCompleteThirdPartyForm(
expert: any,
requestId: string,
formData: any, // ExpertCompleteThirdPartyFormDto
): Promise<RequestManagementDtoRs> {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Verify this is an expert-initiated IN_PERSON file
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
if (request.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException(
"This endpoint is only for IN_PERSON files. LINK-based files should be completed by users step-by-step.",
);
}
// Verify expert access
await this.verifyExpertAccess(request, expert);
// Validate second party is provided for THIRD_PARTY type
if (request.type === "THIRD_PARTY" && !formData.secondParty) {
throw new BadRequestException(
"Second party information is required for THIRD_PARTY type files",
);
}
// Validate guilty party is provided for THIRD_PARTY type
if (request.type === "THIRD_PARTY" && !formData.guiltyPartyPhoneNumber) {
throw new BadRequestException(
"Guilty party phone number is required for THIRD_PARTY type files",
);
}
// Validate carBodyForm is provided for CAR_BODY type
if (request.type === "CAR_BODY" && !formData.carBodyForm) {
throw new BadRequestException(
"CAR_BODY form is required for CAR_BODY type files",
);
}
// Validate phone numbers are different
if (
request.type === "THIRD_PARTY" &&
formData.firstPartyPhoneNumber === formData.secondParty.phoneNumber
) {
throw new BadRequestException(
"First and second party phone numbers must be different",
);
}
// Validate guilty party phone number matches one of the parties
if (request.type === "THIRD_PARTY") {
const guiltyMatchesFirst =
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber;
const guiltyMatchesSecond =
formData.guiltyPartyPhoneNumber === formData.secondParty.phoneNumber;
if (!guiltyMatchesFirst && !guiltyMatchesSecond) {
throw new BadRequestException(
"Guilty party phone number must match either first or second party phone number",
);
}
}
try {
// Get or create user for first party phone number
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
// Collect all steps to be added
const stepsToAdd: StepsEnum[] = [StepsEnum.F_InitialForm];
// Initialize firstPartyDetails structure completely
const firstPartyDetails: any = {
firstPartyId: firstPartyUserId,
firstPartyPhoneNumber: formData.firstPartyPhoneNumber,
firstPartyInitialForm: {
expertOpinion: formData.firstPartyInitialForm.expertOpinion,
imDamaged: formData.firstPartyInitialForm.imDamaged,
imGuilty: formData.firstPartyInitialForm.imGuilty,
},
firstPartyFile: {
descriptions: [formData.firstPartyDescription.desc],
},
};
// Get or create user for second party phone number
const secondPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.secondParty.phoneNumber,
);
const secondPartyDetails: any = {
secondPartyId: secondPartyUserId,
secondPartyPhoneNumber: formData.secondParty.phoneNumber,
secondPartyInitialForm: {
expertOpinion: formData.secondParty.initialForm.expertOpinion,
imDamaged: formData.secondParty.initialForm.imDamaged,
imGuilty: formData.secondParty.initialForm.imGuilty,
},
secondPartyFiles: {
descriptions: [formData.secondParty.description.desc],
},
};
// Build comprehensive update payload
const updatePayload: any = {
$set: {
filledBy: FilledBy.EXPERT,
firstPartyDetails: firstPartyDetails,
firstPartyLocation: formData.firstPartyLocation,
secondPartyDetails: secondPartyDetails,
secondPartyLocation: formData.secondParty.location,
},
};
// Add steps for location and description
stepsToAdd.push(StepsEnum.F_addLocation);
stepsToAdd.push(StepsEnum.F_addDescription);
stepsToAdd.push(StepsEnum.S_InitialForm);
stepsToAdd.push(StepsEnum.S_addLocation);
stepsToAdd.push(StepsEnum.S_addDescription);
// Process first party plate with SandHub lookup
const firstPartyPlate = formData.firstPartyPlate;
try {
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: firstPartyPlate.plate,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
});
const sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
// Try to find client by company code first (more reliable)
const client = companyCode
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: await this.clientService.findOne({ clientName: clientName });
if (!client) {
throw new NotFoundException(
`Client not found for company: ${clientName}`,
);
}
// Add first party plate and related data to the existing firstPartyDetails object
firstPartyDetails.nationalCodeOfInsurer =
firstPartyPlate.nationalCodeOfInsurer;
firstPartyDetails.firstPartyPlate = firstPartyPlate.plate;
firstPartyDetails.firstPartyCarDetail = {
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
insurerBirthday: firstPartyPlate.insurerBirthday,
driverBirthday: firstPartyPlate.driverBirthday,
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNewCar: firstPartyPlate.isNewCar,
};
firstPartyDetails.firstPartyClient = {
clientId: (client as any)["_doc"]?._id || (client as any)._id,
clientName: clientName,
};
firstPartyDetails.firstPartyCertificate =
firstPartyPlate.userNoCertificate;
firstPartyDetails.firstPartyInsuranceDetail = {
insuranceId:
sandHubReport?.LastCompanyDocumentNumber ||
sandHubReport?.DocumentNumber,
insuranceCompany: clientName,
financialCommitmentCeiling:
sandHubReport?.FinancialCvrCptl ||
sandHubReport?.FinancialCommitment,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
};
// Update the firstPartyDetails in the payload with all plate data
updatePayload.$set.firstPartyDetails = firstPartyDetails;
stepsToAdd.push(StepsEnum.F_addPlate);
} catch (plateError) {
this.logger.error("Error processing first party plate:", plateError);
throw new InternalServerErrorException(
"Failed to process first party plate information",
);
}
// Process second party plate
try {
const secondPartyPlate = formData.secondParty.plate;
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: secondPartyPlate.plate,
nationalCodeOfInsurer: secondPartyPlate.nationalCodeOfInsurer,
});
const sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
// Try to find client by company code first (more reliable)
const client = companyCode
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: await this.clientService.findOne({ clientName: clientName });
if (!client) {
throw new NotFoundException(
`Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`,
);
}
// Add second party plate and related data to the existing secondPartyDetails object
secondPartyDetails.nationalCodeOfInsurer =
secondPartyPlate.nationalCodeOfInsurer;
secondPartyDetails.secondPartyPlate = secondPartyPlate.plate;
secondPartyDetails.secondPartyCarDetail = {
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
insurerBirthday: secondPartyPlate.insurerBirthday,
driverBirthday: secondPartyPlate.driverBirthday,
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNewCar: secondPartyPlate.isNewCar,
};
secondPartyDetails.secondPartyClient = {
clientId: (client as any)["_doc"]?._id || (client as any)._id,
clientName: clientName,
};
secondPartyDetails.secondPartyCertificate =
secondPartyPlate.userNoCertificate;
secondPartyDetails.secondPartyInsuranceDetail = {
insuranceId:
sandHubReport?.LastCompanyDocumentNumber ||
sandHubReport?.DocumentNumber,
insuranceCompany: clientName,
financialCommitmentCeiling:
sandHubReport?.FinancialCvrCptl ||
sandHubReport?.FinancialCommitment,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
};
// Update the secondPartyDetails in the payload
updatePayload.$set.secondPartyDetails = secondPartyDetails;
stepsToAdd.push(StepsEnum.S_addPlate);
} catch (plateError) {
this.logger.error("Error processing second party plate:", plateError);
throw new InternalServerErrorException(
"Failed to process second party plate information",
);
}
// Determine which party is guilty based on phone number
const guiltyMatchesFirst =
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber;
const guiltyUserId = guiltyMatchesFirst
? firstPartyUserId
: secondPartyDetails.secondPartyId;
// Create initial expertSubmitReply with guiltyUserId
// Accident fields can be added separately via add-accident-fields endpoint
// This allows the file to proceed directly to signatures without expert review
updatePayload.$set.expertSubmitReply = {
description:
"Expert completed all information on-site. Awaiting signatures.",
guiltyUserId: guiltyUserId,
submitTime: new Date(),
fields: {
accidentWay: {
id: "",
label: "To be determined",
},
accidentReason: {
id: "",
label: "To be determined",
fanavaran: 0,
},
accidentType: {
id: "",
label: "To be determined",
},
},
firstPartyComment: null,
secondPartyComment: null,
};
updatePayload.$set.currentStep = StepsEnum.S_addDescription;
updatePayload.$set.nextStep = StepsEnum.AddSignatures;
updatePayload.$set.blameStatus = ReqBlameStatus.WaitingForSignatures;
// Add all steps at once using $each to push multiple values
if (stepsToAdd.length > 0) {
updatePayload.$push = {
steps: { $each: stepsToAdd },
};
}
// Execute the update
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
// Ensure expert assignment
await this.ensureExpertAssignment(requestId);
// Add history events
await this.addHistoryEvent(
requestId,
"EXPERT_COMPLETED_ALL_INFORMATION",
new Types.ObjectId(expert.sub),
undefined,
"expert",
{
type: "THIRD_PARTY",
hasSecondParty: true,
},
);
await this.addHistoryEvent(
requestId,
"READY_FOR_SIGNATURES",
new Types.ObjectId(expert.sub),
undefined,
"system",
);
// Read updated request
const updatedRequest =
await this.requestManagementDbService.findOne(requestId);
return new RequestManagementDtoRs(
updatedRequest.firstPartyDetails?.firstPartyId,
updatedRequest?.secondPartyDetails?.secondPartyId || null,
StepsEnum.S_addDescription,
StepsEnum.AddSignatures,
"All information completed. Waiting for user signatures.",
updatedRequest._id.toString(),
);
} catch (error) {
this.logger.error("Error in expertCompleteForm:", error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException(
"Failed to complete form. Please try again.",
);
}
}
/**
* Expert completes all information for IN_PERSON CAR_BODY file at once
* This replaces the step-by-step flow for experts filling files on-site
*/
async expertCompleteCarBodyForm(
expert: any,
requestId: string,
formData: any, // ExpertCompleteCarBodyFormDto
): Promise<RequestManagementDtoRs> {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Verify this is an expert-initiated IN_PERSON CAR_BODY file
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
if (request.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException(
"This endpoint is only for IN_PERSON files. LINK-based files should be completed by users step-by-step.",
);
}
if (request.type !== "CAR_BODY") {
throw new BadRequestException(
"This endpoint is only for CAR_BODY type files. Use complete-form-third-party for THIRD_PARTY files.",
);
}
// Verify expert access
await this.verifyExpertAccess(request, expert);
// Validate carBodyForm is provided
if (!formData.carBodyForm) {
throw new BadRequestException(
"CAR_BODY form is required for CAR_BODY type files",
);
}
try {
// Get or create user for first party phone number
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
formData.firstPartyPhoneNumber,
);
// Collect all steps to be added
const stepsToAdd: StepsEnum[] = [StepsEnum.CarBodyForm];
// Initialize firstPartyDetails structure completely
const firstPartyDetails: any = {
firstPartyId: firstPartyUserId,
firstPartyPhoneNumber: formData.firstPartyPhoneNumber,
firstPartyInitialForm: {
expertOpinion: formData.firstPartyInitialForm.expertOpinion,
imDamaged: formData.firstPartyInitialForm.imDamaged,
imGuilty: formData.firstPartyInitialForm.imGuilty,
},
firstPartyFile: {
descriptions: [formData.firstPartyDescription.desc],
},
carBodyForm: {
firstPartyId: firstPartyUserId,
firstPartyPhoneNumber: formData.firstPartyPhoneNumber,
carBodyForm: formData.carBodyForm,
// Assume guilty by default if accident was with another car
isGuilty: formData.carBodyForm.car ? true : false,
},
};
// CAR_BODY specific fields
if (formData.firstPartyDescription.accidentDate) {
firstPartyDetails.firstPartyFile.accidentDate =
formData.firstPartyDescription.accidentDate;
}
if (formData.firstPartyDescription.accidentTime) {
firstPartyDetails.firstPartyFile.accidentTime =
formData.firstPartyDescription.accidentTime;
}
if (formData.firstPartyDescription.weatherCondition) {
firstPartyDetails.firstPartyFile.weatherCondition =
formData.firstPartyDescription.weatherCondition;
}
if (formData.firstPartyDescription.roadCondition) {
firstPartyDetails.firstPartyFile.roadCondition =
formData.firstPartyDescription.roadCondition;
}
if (formData.firstPartyDescription.lightCondition) {
firstPartyDetails.firstPartyFile.lightCondition =
formData.firstPartyDescription.lightCondition;
}
// Build comprehensive update payload
const updatePayload: any = {
$set: {
filledBy: FilledBy.EXPERT,
firstPartyDetails: firstPartyDetails,
firstPartyLocation: formData.firstPartyLocation,
},
};
// Add steps for location and description
stepsToAdd.push(StepsEnum.F_addLocation);
stepsToAdd.push(StepsEnum.F_addDescription);
// Process first party plate with SandHub lookup
const firstPartyPlate = formData.firstPartyPlate;
try {
const sandHubResponse = await this.sandHubService.getSandHubResponse({
plate: firstPartyPlate.plate,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
});
const sandHubReport = sandHubResponse["_doc"] || sandHubResponse;
const clientName =
sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
const companyCode = sandHubReport?.CompanyCode;
// Try to find client by company code first (more reliable)
const client = companyCode
? await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
)
: await this.clientService.findOne({ clientName: clientName });
if (!client) {
throw new NotFoundException(
`Client not found for company: ${clientName}`,
);
}
// Add first party plate and related data to the existing firstPartyDetails object
firstPartyDetails.nationalCodeOfInsurer =
firstPartyPlate.nationalCodeOfInsurer;
firstPartyDetails.firstPartyPlate = firstPartyPlate.plate;
firstPartyDetails.firstPartyCarDetail = {
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
insurerBirthday: firstPartyPlate.insurerBirthday,
driverBirthday: firstPartyPlate.driverBirthday,
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
isNewCar: firstPartyPlate.isNewCar,
};
firstPartyDetails.firstPartyClient = {
clientId: (client as any)["_doc"]?._id || (client as any)._id,
clientName: clientName,
};
firstPartyDetails.firstPartyCertificate =
firstPartyPlate.userNoCertificate;
firstPartyDetails.firstPartyInsuranceDetail = {
insuranceId:
sandHubReport?.LastCompanyDocumentNumber ||
sandHubReport?.DocumentNumber,
insuranceCompany: clientName,
financialCommitmentCeiling:
sandHubReport?.FinancialCvrCptl ||
sandHubReport?.FinancialCommitment,
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
endDate: sandHubReport?.EndDate,
};
// CAR_BODY specific insurance info
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
request._id.toString(),
firstPartyPlate.plate,
firstPartyPlate.nationalCodeOfInsurer,
);
firstPartyDetails.firstPartyCarBodyInsuranceDetail = {
policyNumber: carBodyInfo.policyNumber,
startDate: carBodyInfo.startDate,
endDate: carBodyInfo.endDate,
insurerCompany: carBodyInfo.insurerCompany,
coverages: carBodyInfo.coverages,
};
// Update the firstPartyDetails in the payload with all plate data
updatePayload.$set.firstPartyDetails = firstPartyDetails;
stepsToAdd.push(StepsEnum.F_addPlate);
} catch (plateError) {
this.logger.error("Error processing first party plate:", plateError);
throw new InternalServerErrorException(
"Failed to process first party plate information",
);
}
// For CAR_BODY: Create expertSubmitReply
// First party is guilty by default if accident was with another car
const guiltyUserId = firstPartyUserId;
// Accident fields can be added separately via add-accident-fields endpoint
updatePayload.$set.expertSubmitReply = {
description:
"Expert completed all information on-site. Awaiting signature.",
guiltyUserId: guiltyUserId,
submitTime: new Date(),
fields: {
accidentWay: {
id: "",
label: "To be determined",
},
accidentReason: {
id: "",
label: "To be determined",
fanavaran: 0,
},
accidentType: {
id: "",
label: "To be determined",
},
},
firstPartyComment: null,
secondPartyComment: null,
};
updatePayload.$set.currentStep = StepsEnum.F_addDescription;
updatePayload.$set.nextStep = StepsEnum.AddSignatures;
updatePayload.$set.blameStatus = ReqBlameStatus.WaitingForSignatures;
// Add all steps at once using $each to push multiple values
if (stepsToAdd.length > 0) {
updatePayload.$push = {
steps: { $each: stepsToAdd },
};
}
// Execute the update
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
// Ensure expert assignment
await this.ensureExpertAssignment(requestId);
// Add history events
await this.addHistoryEvent(
requestId,
"EXPERT_COMPLETED_ALL_INFORMATION",
new Types.ObjectId(expert.sub),
undefined,
"expert",
{
type: "CAR_BODY",
hasSecondParty: false,
},
);
await this.addHistoryEvent(
requestId,
"READY_FOR_SIGNATURES",
new Types.ObjectId(expert.sub),
undefined,
"system",
);
// Read updated request
const updatedRequest =
await this.requestManagementDbService.findOne(requestId);
return new RequestManagementDtoRs(
updatedRequest.firstPartyDetails?.firstPartyId,
null,
StepsEnum.F_addDescription,
StepsEnum.AddSignatures,
"All information completed. Waiting for user signature.",
updatedRequest._id.toString(),
);
} catch (error) {
this.logger.error("Error in expertCompleteCarBodyForm:", error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException(
"Failed to complete form. Please try again.",
);
}
}
/**
* Expert uploads video for expert-initiated file
* Only one video is needed per file (not per party)
*/
async expertUploadVideo(
expert: any,
requestId: string,
file: Express.Multer.File,
): Promise<RequestManagementDtoRs> {
if (!file) {
throw new BadRequestException("Video file is required");
}
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Verify this is an expert-initiated file
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
// Verify expert access
await this.verifyExpertAccess(request, expert);
// Check if video already exists
const stepToCheck =
request.type === "THIRD_PARTY"
? StepsEnum.F_videoUpload
: StepsEnum.CarBodyVideo;
if (request.steps?.includes(stepToCheck)) {
throw new BadRequestException(
"Video has already been uploaded for this file",
);
}
try {
// Create video document
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
});
// Determine the step based on request type
const stepToPush =
request.type === "THIRD_PARTY"
? StepsEnum.F_videoUpload
: StepsEnum.CarBodyVideo;
const currentStepValue = stepToPush;
const nextStepValue = StepsEnum.F_addPlate;
// Update request with video
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$push: { steps: stepToPush },
$set: {
currentStep: currentStepValue,
"firstPartyDetails.firstPartyFile.firstPartyVideoId":
videoDocument["_doc"]._id,
nextStep: nextStepValue,
},
},
);
// Add history event
await this.addHistoryEvent(
requestId,
"EXPERT_UPLOADED_VIDEO",
new Types.ObjectId(expert.sub),
undefined,
"expert",
{
videoId: videoDocument["_doc"]._id.toString(),
fileName: file.filename,
},
);
return new RequestManagementDtoRs(
request.firstPartyDetails?.firstPartyId ||
new Types.ObjectId(expert.sub),
request?.secondPartyDetails?.secondPartyId || null,
currentStepValue,
nextStepValue,
"Video uploaded successfully",
request._id,
);
} catch (error) {
this.logger.error("Error uploading expert video:", error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException("Failed to upload video");
}
}
/**
* Expert uploads voice for expert-initiated file
* Only one voice is needed per file (not per party)
*/
async expertUploadVoice(
expert: any,
requestId: string,
voice: Express.Multer.File,
): Promise<RequestManagementDtoRs> {
if (!voice) {
throw new BadRequestException("Voice file is required");
}
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Verify this is an expert-initiated file
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
// Verify expert access
await this.verifyExpertAccess(request, expert);
// Check if voice already exists (check both first and second party steps)
if (
request.steps?.includes(StepsEnum.F_addVoice) ||
request.steps?.includes(StepsEnum.S_addVoice)
) {
throw new BadRequestException(
"Voice has already been uploaded for this file",
);
}
try {
// Create voice document
const voiceDoc = await this.blameVoiceDbService.create({
fileName: voice.filename,
path: voice.path,
requestId: new Types.ObjectId(requestId),
context: UploadContext.INITIAL, // Use INITIAL context for expert uploads
});
// Determine current step and next step based on request state
// If description is already added, next step is different
const hasDescription =
request.steps?.includes(StepsEnum.F_addDescription) ||
request.steps?.includes(StepsEnum.S_addDescription);
const currentStep =
request.type === "THIRD_PARTY"
? StepsEnum.F_addVoice
: StepsEnum.F_addVoice;
const nextStep = hasDescription
? request.type === "THIRD_PARTY"
? StepsEnum.AddSignatures
: StepsEnum.AddSignatures
: request.type === "THIRD_PARTY"
? StepsEnum.F_addDescription
: StepsEnum.F_addDescription;
// Update request with voice
// For expert uploads, we store it in firstPartyFile but it represents the entire file
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$push: {
"firstPartyDetails.firstPartyFile.voices": voiceDoc["_id"],
steps: StepsEnum.F_addVoice,
},
$set: {
currentStep: currentStep,
nextStep: nextStep,
},
},
);
// Add history event
await this.addHistoryEvent(
requestId,
"EXPERT_UPLOADED_VOICE",
new Types.ObjectId(expert.sub),
undefined,
"expert",
{
voiceId: voiceDoc["_id"].toString(),
fileName: voice.filename,
},
);
const message = hasDescription
? "Voice uploaded successfully. File is ready for signatures."
: "Voice uploaded successfully. Please add description.";
return new RequestManagementDtoRs(
request.firstPartyDetails?.firstPartyId ||
new Types.ObjectId(expert.sub),
request?.secondPartyDetails?.secondPartyId || null,
currentStep,
nextStep,
message,
request._id,
);
} catch (error) {
this.logger.error("Error uploading expert voice:", error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException("Failed to upload voice");
}
}
/**
* Expert adds accident fields to expert-initiated file
* Can be used for both IN_PERSON and LINK-based files
*/
async expertAddAccidentFields(
expert: any,
requestId: string,
fields: { accidentWay: any; accidentReason: any; accidentType: any },
): Promise<{ message: string; requestId: string }> {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Verify this is an expert-initiated file
if (!request.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files",
);
}
// Verify expert access
await this.verifyExpertAccess(request, expert);
// Determine which reply field to update
const isObjection = !!request.expertResendReply;
const replyField = isObjection
? "expertSubmitReplyFinal"
: "expertSubmitReply";
// Get existing reply or create new structure
const existingReply = request[replyField] || {};
const newReplyObject: any = {
...existingReply,
fields: {
accidentWay: fields.accidentWay,
accidentReason: fields.accidentReason,
accidentType: fields.accidentType,
},
};
// Preserve other fields if they exist (for SubmitReply structure)
if (existingReply && typeof existingReply === "object") {
if ("description" in existingReply) {
newReplyObject.description = existingReply.description;
} else {
newReplyObject.description = "Expert on-site assessment";
}
if ("guiltyUserId" in existingReply) {
newReplyObject.guiltyUserId = existingReply.guiltyUserId;
} else {
newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId;
}
if ("submitTime" in existingReply) {
newReplyObject.submitTime = existingReply.submitTime;
} else {
newReplyObject.submitTime = new Date();
}
if ("firstPartyComment" in existingReply) {
newReplyObject.firstPartyComment = existingReply.firstPartyComment;
} else {
newReplyObject.firstPartyComment = null;
}
if ("secondPartyComment" in existingReply) {
newReplyObject.secondPartyComment = existingReply.secondPartyComment;
} else {
newReplyObject.secondPartyComment = null;
}
} else {
// Create new structure if no existing reply
newReplyObject.description = "Expert on-site assessment";
newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId;
newReplyObject.submitTime = new Date();
newReplyObject.firstPartyComment = null;
newReplyObject.secondPartyComment = null;
}
// Update the file
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$set: {
[replyField]: newReplyObject,
},
},
);
// Add history event
await this.addHistoryEvent(
requestId,
"EXPERT_ADDED_ACCIDENT_FIELDS",
new Types.ObjectId(expert.sub),
undefined,
"expert",
{
hasObjection: isObjection,
},
);
return {
message: "Accident fields added successfully",
requestId: requestId,
};
}
/**
* Check if request is expert-initiated and should be auto-assigned
*/
async ensureExpertAssignment(requestId: string): Promise<void> {
const request = await this.requestManagementDbService.findOne(requestId);
if (request?.expertInitiated && request.initiatedBy) {
// Ensure the file is assigned to the initiating expert
if (
!request.assignedExpertId ||
String(request.assignedExpertId) !== String(request.initiatedBy)
) {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
$set: {
assignedExpertId: request.initiatedBy,
},
},
);
// Add history event
await this.addHistoryEvent(
requestId,
"AUTO_ASSIGNED_TO_EXPERT",
request.initiatedBy,
undefined,
"system",
{
expertId: request.initiatedBy.toString(),
},
);
}
}
}
/**
* Get or create a user by phone number
* Used for IN_PERSON expert-initiated files where expert provides phone numbers
*/
private async getOrCreateUserByPhoneNumber(
phoneNumber: string,
): Promise<Types.ObjectId> {
const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim();
let user = await this.userDbService.findOne(
buildUserLookupByPhone(canonical),
);
if (user) {
return new Types.ObjectId((user as any)._id);
}
const newUser = await this.userDbService.createUser({
mobile: canonical,
username: canonical,
otp: "",
tokens: {
token: "",
rfToken: "",
},
fullName: "",
nationalCode: "",
lastLogin: new Date(),
clientKey: new Types.ObjectId(),
birthDay: "",
city: "",
address: "",
state: "",
otpExpire: 0,
});
return new Types.ObjectId((newUser as any)._id);
}
/**
* V2: Get list of documents/items the current user needs to resend
*/
async getResendRequirementsV2(
requestId: string,
userId: string,
): Promise<UserResendResponseDto> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Check if status is WAITING_FOR_DOCUMENT_RESEND
if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) {
throw new BadRequestException(
"This request is not waiting for document resend",
);
}
// Find the current user's party
const parties = request.parties || [];
const userParty = parties.find(
(p) => String(p.person?.userId) === String(userId),
);
if (!userParty) {
throw new ForbiddenException("You are not a party in this request");
}
// Find resend request for this party (partyId matches person.userId)
const resendParties = request.expert?.resend?.parties || [];
const userResendRequest = resendParties.find(
(r) => String(r.partyId) === String(userParty.person?.userId),
);
if (!userResendRequest) {
throw new NotFoundException(
"No resend request found for your party. Please contact support.",
);
}
const items = normalizeResendRequestedItemsList(
userResendRequest.requestedItems as string[],
);
const row = userResendRequest as any;
const merged = {
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
resendVoiceId: row.resendVoiceId,
resendVideoId: row.resendVideoId,
userTextDescription: row.userTextDescription,
};
const remainingItems = items.filter(
(i) => !isResendPartyItemSatisfied(i, merged),
);
return {
requestId: String(request._id),
requestedItems: items,
description: userResendRequest.description || "",
completed: userResendRequest.completed || false,
requestedItemsUi: buildResendItemsWithUi(items),
remainingItems,
};
}
/**
* V2: Upload requested documents/evidence for resend
*/
async uploadResendDocumentsV2(
requestId: string,
userId: string,
files: {
drivingLicense?: Express.Multer.File[];
carCertificate?: Express.Multer.File[];
nationalCertificate?: Express.Multer.File[];
carGreenCard?: Express.Multer.File[];
voice?: Express.Multer.File[];
video?: Express.Multer.File[];
},
textDescription?: string,
): Promise<UserResendUploadResponseDto> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Check status
if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) {
throw new BadRequestException(
"This request is not waiting for document resend",
);
}
// Find user's party
const parties = request.parties || [];
const userParty = parties.find(
(p) => String(p.person?.userId) === String(userId),
);
if (!userParty) {
throw new ForbiddenException("You are not a party in this request");
}
const partyIndex = parties.findIndex(
(p) => String(p.person?.userId) === String(userId),
);
if (partyIndex === -1) {
throw new ForbiddenException("Party not found in request");
}
// Find resend request for this party (partyId matches person.userId)
const resendParties = request.expert?.resend?.parties || [];
const resendIndex = resendParties.findIndex(
(r) => String(r.partyId) === String(userParty.person?.userId),
);
if (resendIndex === -1) {
throw new NotFoundException("No resend request found for your party");
}
const userResendRequest = resendParties[resendIndex];
const requestedItems = normalizeResendRequestedItemsList(
userResendRequest.requestedItems as string[],
);
if (requestedItems.length === 0) {
throw new BadRequestException(
"No valid requested items are configured for your party resend. Please contact support.",
);
}
const row = userResendRequest as any;
const mergedRow = {
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
resendVoiceId: row.resendVoiceId,
resendVideoId: row.resendVideoId,
userTextDescription: row.userTextDescription,
};
const safeFiles = files || {};
const hasAnyFile = Object.keys(safeFiles).some((fieldName) => {
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
return fileArray && fileArray.length > 0 && fileArray[0];
});
const descTrim =
textDescription != null ? String(textDescription).trim() : "";
const sendingDescription =
requestedItems.includes(ResendItemType.DESCRIPTION) &&
descTrim.length > 0;
if (!hasAnyFile && !sendingDescription) {
throw new BadRequestException(
"Provide at least one file for a requested item and/or the text description when the expert asked for a description.",
);
}
const uploadedItems: string[] = [];
const updatePayload: any = { $set: {} };
if (sendingDescription) {
updatePayload.$set[
`expert.resend.parties.${resendIndex}.userTextDescription`
] = descTrim;
mergedRow.userTextDescription = descTrim;
uploadedItems.push(ResendItemType.DESCRIPTION);
}
for (const fieldName of Object.keys(safeFiles)) {
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
if (!fileArray || fileArray.length === 0) continue;
const file = fileArray[0];
const canonKey = normalizeResendRequestedItemKey(fieldName);
if (!canonKey || !requestedItems.includes(canonKey)) {
throw new BadRequestException(
`${fieldName} was not requested or is not a valid resend field. Requested items: ${requestedItems.join(", ")}`,
);
}
if (canonKey === ResendItemType.VOICE) {
const voiceDoc = await this.blameVoiceDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
fileName: file.filename,
context: "EXPERT_RESEND" as any,
});
updatePayload.$addToSet = updatePayload.$addToSet || {};
updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] = (
voiceDoc as any
)._id;
updatePayload.$set[
`expert.resend.parties.${resendIndex}.resendVoiceId`
] = (voiceDoc as any)._id;
mergedRow.resendVoiceId = (voiceDoc as any)._id;
uploadedItems.push(canonKey);
// } else if (canonKey === ResendItemType.VIDEO) {
// const videoDoc = await this.blameVideoDbService.create({
// path: file.path,
// requestId: new Types.ObjectId(requestId),
// fileName: file.filename,
// });
// updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] = String(
// (videoDoc as any)._id,
// );
// updatePayload.$set[
// `expert.resend.parties.${resendIndex}.resendVideoId`
// ] = (videoDoc as any)._id;
// mergedRow.resendVideoId = (videoDoc as any)._id;
uploadedItems.push(canonKey);
} else {
const docType = canonKey as BlameDocumentType;
const doc = await this.blameDocumentDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
fileName: file.filename,
documentType: docType,
});
updatePayload.$set[
`expert.resend.parties.${resendIndex}.uploadedDocuments.${canonKey}`
] = (doc as any)._id;
mergedRow.uploadedDocuments[canonKey] = (doc as any)._id;
uploadedItems.push(canonKey);
}
}
const allItemsCompleted =
requestedItems.length > 0 &&
requestedItems.every((item) =>
isResendPartyItemSatisfied(item, mergedRow),
);
if (allItemsCompleted) {
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] =
true;
updatePayload.$set[`expert.resend.parties.${resendIndex}.completedAt`] =
new Date();
}
await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
const updatedRequest = await this.blameRequestDbService.findById(requestId);
const allPartiesCompleted = updatedRequest?.expert?.resend?.parties?.every(
(p) => p.completed,
);
if (allPartiesCompleted) {
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.WAITING_FOR_EXPERT,
"workflow.currentStep": WorkflowStep.WAITING_FOR_GUILT_DECISION,
"workflow.nextStep": null,
},
$push: {
"workflow.completedSteps": WorkflowStep.WAITING_FOR_DOCUMENT_RESEND,
},
});
await this.applyLinkedClaimsBlameResendCleared(requestId);
this.logger.log(
`All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`,
);
}
return {
message: allItemsCompleted
? "All requested documents uploaded successfully"
: "Documents uploaded successfully. Please upload remaining items.",
uploadedItems,
allItemsCompleted,
};
}
/**
* V2: User signs and accepts/rejects (expert decision for THIRD_PARTY, or final acknowledgment for CAR_BODY).
*/
async userSignDecisionV2(
requestId: string,
userId: string,
isAccept: boolean,
signFile: Express.Multer.File,
): Promise<UserSignatureResponseDto> {
if (!signFile) {
throw new BadRequestException("A signature file is required");
}
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate status is WAITING_FOR_SIGNATURES
if (request.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
throw new BadRequestException(
"Request is not waiting for signatures. Current status: " +
request.status,
);
}
// THIRD_PARTY: blame expert must have issued a guilt decision before signatures.
// CAR_BODY: single-party flow; no blame expert / no expert.decision by design.
if (
request.type !== BlameRequestType.CAR_BODY &&
!request.expert?.decision
) {
throw new BadRequestException("Expert has not made a decision yet");
}
// Find user's party
const parties = request.parties || [];
const partyIndex = parties.findIndex(
(p) => String(p.person?.userId) === String(userId),
);
if (partyIndex === -1) {
throw new ForbiddenException("You are not a party in this request");
}
const userParty = parties[partyIndex];
// Check if user already signed
if (userParty.confirmation) {
throw new BadRequestException("You have already signed this decision");
}
// Create signature document
const signDoc = await this.userSign.create({
fileName: signFile.filename,
userId: userId,
path: signFile.path,
requestId: new Types.ObjectId(requestId),
});
const signatureData = {
fileId: String((signDoc as any)._id),
fileName: signFile.filename,
fileUrl: signFile.path,
};
const confirmationPayload = {
partyRole: userParty.role,
accepted: isAccept,
signature: signatureData,
};
// Any single rejection stops the case immediately (in-person resolution required).
if (!isAccept) {
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.STOPPED,
[`parties.${partyIndex}.confirmation`]: confirmationPayload,
"workflow.nextStep": null,
},
$push: {
history: {
type: "PARTY_REJECTED_EXPERT_DECISION",
timestamp: new Date(),
metadata: {
userId,
partyRole: userParty.role,
},
},
},
});
this.logger.log(
`Party rejected expert decision for request ${requestId}; status STOPPED.`,
);
return {
requestId: String(request._id),
accepted: false,
status: CaseStatus.STOPPED,
message:
"You rejected the expert decision. This case is stopped and requires in-person resolution.",
};
}
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
[`parties.${partyIndex}.confirmation`]: confirmationPayload,
},
});
const updatedRequest = await this.blameRequestDbService.findById(requestId);
const allPartiesSigned = updatedRequest.parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES;
let message = "Your signature has been recorded successfully";
if (allPartiesSigned) {
const allAccepted = updatedRequest.parties.every(
(p) => p.confirmation?.accepted === true,
);
if (allAccepted) {
finalStatus = CaseStatus.COMPLETED;
message =
updatedRequest.parties.length === 1
? "Case completed successfully."
: "Both parties have accepted. Case completed successfully.";
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.COMPLETED,
"workflow.currentStep": "COMPLETED",
"workflow.nextStep": null,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
},
});
if (
updatedRequest.type === BlameRequestType.THIRD_PARTY ||
updatedRequest.type === BlameRequestType.CAR_BODY
) {
let targetPhone: string | undefined;
if (updatedRequest.type === BlameRequestType.THIRD_PARTY) {
const guiltyPartyId = updatedRequest.expert?.decision?.guiltyPartyId
? String(updatedRequest.expert.decision.guiltyPartyId)
: null;
if (guiltyPartyId) {
const damagedParty = updatedRequest.parties?.find(
(p) =>
p.person?.userId && String(p.person.userId) !== guiltyPartyId,
);
targetPhone = damagedParty?.person?.phoneNumber?.trim();
}
} else {
// CAR_BODY has a single owner/damaged party: notify that user too.
const ownerParty =
updatedRequest.parties?.[partyIndex] ??
updatedRequest.parties?.[0];
targetPhone = ownerParty?.person?.phoneNumber?.trim();
}
if (targetPhone) {
const publicId = String(updatedRequest.publicId);
const link = this.smsOrchestrationService.buildBlamePartyLink(
requestId,
targetPhone === request?.parties[0]?.person.phoneNumber
? "FIRST"
: "SECOND",
);
await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice(
{
receptor: targetPhone,
publicId,
link,
},
);
}
}
} else {
finalStatus = CaseStatus.STOPPED;
message =
"One party accepted and another rejected the decision. Case is stopped; in-person resolution required.";
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
status: CaseStatus.STOPPED,
"workflow.nextStep": null,
},
$push: {
history: {
type: "PARTIES_DISAGREED_ON_EXPERT_DECISION",
timestamp: new Date(),
metadata: {},
},
},
});
}
this.logger.log(
`All parties signed for request ${requestId}. Final status: ${finalStatus}`,
);
}
return {
requestId: String(request._id),
accepted: isAccept,
status: finalStatus,
message,
};
}
// ---------------------------------------------------------------------------
// V3 IN-PERSON SEQUENTIAL FLOW (field expert, one party at a time)
// ---------------------------------------------------------------------------
private assertBlameV3ExpertInPerson(req: any): void {
if (req.creationMethod !== CreationMethod.IN_PERSON) {
throw new BadRequestException("V3 flow is only for IN_PERSON files.");
}
if (!req.expertInitiated) {
throw new BadRequestException(
"V3 flow is only for expert-initiated files.",
);
}
}
/** Resolve auto-created claim for V3 blame (created on guilty-party run-inquiries). */
async getV3LinkedClaimForExpert(
expert: any,
blameRequestId: string,
): Promise<{
blameRequestId: string;
claimRequestId: string;
publicId: string;
}> {
const req = await this.blameRequestDbService.findById(blameRequestId);
if (!req) throw new NotFoundException("Blame request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (!claim) {
throw new NotFoundException(
"No claim linked to this blame file yet. Complete guilty-party run-inquiries first.",
);
}
return {
blameRequestId: String(req._id),
claimRequestId: String(claim._id),
publicId: claim.publicId ?? req.publicId,
};
}
private hasPartyInquiriesComplete(req: any, role: PartyRole): boolean {
const step =
role === PartyRole.FIRST
? WorkflowStep.FIRST_INITIAL_FORM
: WorkflowStep.SECOND_INITIAL_FORM;
return (req.workflow?.completedSteps ?? []).includes(step);
}
private partyHasSigned(req: any, role: PartyRole): boolean {
const idx = this.getPartyIndex(req, role);
return idx !== -1 && req.parties?.[idx]?.confirmation != null;
}
private resolveV3InquiryPartyRole(req: any): PartyRole | null {
if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) {
return PartyRole.FIRST;
}
if (
req.type === BlameRequestType.THIRD_PARTY &&
!this.hasPartyInquiriesComplete(req, PartyRole.SECOND)
) {
return PartyRole.SECOND;
}
return null;
}
private pushWorkflowSteps(req: any, steps: WorkflowStep[]): void {
if (!req.workflow) req.workflow = {} as any;
const completed = Array.isArray(req.workflow.completedSteps)
? [...req.workflow.completedSteps]
: [];
for (const step of steps) {
if (!completed.includes(step)) completed.push(step);
}
req.workflow.completedSteps = completed;
}
private async runPartyInquiriesV3Internal(
req: any,
partyData: RunInquiriesV3Dto,
partyRole: PartyRole,
party: any,
): Promise<{ clientId?: string }> {
const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND";
let clientId: string | undefined;
const inquiryOptions = (cid?: string) =>
cid ? { clientId: cid } : undefined;
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry(
{
plate: partyData.plate as any,
nationalCodeOfInsurer: partyData.nationalCodeOfInsurer,
},
inquiryOptions(party?.person?.clientId?.toString()),
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) {
this.logger.error(
`[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
if (inquiryMapped?.Error) {
throw new BadRequestException(
inquiryMapped.Error.Message ||
`${roleLabel} party plate inquiry returned an error`,
);
}
const clientName = inquiryMapped?.CompanyName;
const companyCode = inquiryMapped?.CompanyCode;
if (!clientName) {
throw new BadRequestException(
`CompanyName missing from ${roleLabel} party inquiry response`,
);
}
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new BadRequestException(
`CompanyCode missing or invalid in ${roleLabel} party inquiry response`,
);
}
const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id;
clientId = resolvedClientId ? String(resolvedClientId) : undefined;
if (!party.person) party.person = {} as any;
party.person.clientId = resolvedClientId;
party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer;
party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver;
party.person.driverIsInsurer = partyData.driverIsInsurer;
party.person.insurerBirthday = partyData.insurerBirthday;
party.person.driverBirthday =
partyData.driverBirthday ??
(partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null);
if (partyData.insurerLicense)
party.person.insurerLicense = partyData.insurerLicense;
if (partyData.driverLicense)
party.person.driverLicense = partyData.driverLicense;
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.plateId = this.plateToPlateIdString(partyData.plate);
party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
party.vehicle.inquiry = {
source: "TEJARAT_BLOCK_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
};
if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate;
if (
req.type === BlameRequestType.CAR_BODY &&
partyRole === PartyRole.FIRST
) {
try {
const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry(
{
nationalCodeOfInsurer: partyData.nationalCodeOfInsurer,
plate: partyData.plate as any,
},
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
});
party.vehicle.inquiry = {
...party.vehicle.inquiry,
carBody: {
source: "TEJARAT_CAR_BODY_INQUIRY",
raw: carBodyInfo.raw,
mapped: carBodyInfo.mapped,
},
};
const m = carBodyInfo.mapped;
(party.insurance as any).carBodyInsurance = {
policyNumber: m.policyNumber ?? null,
companyId: m.companyId ?? null,
companyName: m.CompanyName ?? null,
};
const cbCompanyCode = m.companyId ?? m.CompanyCode;
const cbCompanyName = m.CompanyName ?? m.companyPersianName;
if (cbCompanyCode && cbCompanyName) {
const cbClient =
await this.clientService.findOrCreateClientByCompanyCode(
Number(cbCompanyCode),
String(cbCompanyName),
);
const cbClientId =
(cbClient as any)?._id ?? (cbClient as any)?._doc?._id;
if (cbClientId) {
party.person.clientId = cbClientId;
clientId = String(cbClientId);
}
}
} catch (err: any) {
this.logger.error(
`[V3] car body inquiry failed for request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"carBody",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`CAR_BODY inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver;
const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday;
if (nationalCode && birthDate != null) {
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
nationalCode,
birthDate as string | number,
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
partyRole,
true,
personalInquiry,
);
} catch (err: any) {
this.logger.error(
`[V3] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
const licenseNationalCode = partyData.driverIsInsurer
? partyData.nationalCodeOfInsurer
: partyData.nationalCodeOfDriver;
const licenseNumber = partyData.driverIsInsurer
? partyData.insurerLicense
: partyData.driverLicense;
if (!licenseNumber) {
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
false,
{ skipped: true, reason: "No license number provided" },
);
} else {
try {
const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo(
licenseNationalCode,
licenseNumber,
clientId ? { clientId } : undefined,
);
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
true,
licenseInquiry,
);
} catch (err: any) {
this.logger.error(
`[V3] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(
req,
"drivingLicence",
partyRole,
false,
{},
err,
);
throw new BadRequestException(
`${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`,
);
}
}
return { clientId };
}
private async validateShebaV3(
partyData: RunInquiriesV3Dto,
clientId?: string,
): Promise<void> {
if (!partyData.sheba) return;
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver;
await this.sandHubService.getShebaValidation(
nationalCode,
partyData.sheba,
clientId ? { clientId } : undefined,
);
}
private markPartyInquiriesCompleteOnBlame(
req: any,
partyRole: PartyRole,
actor: any,
): void {
// V4/V5 FileMaker files skip the location step — it is auto-completed here.
const skipLocation = !!(req as any).isMadeByFileMaker;
if (partyRole === PartyRole.FIRST) {
const completedSteps = skipLocation
? [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION]
: [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM];
this.pushWorkflowSteps(req, completedSteps);
req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION;
req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE;
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
if (guiltyUserId) {
(req.expert as any).decision.guiltyPartyId = new Types.ObjectId(
String(guiltyUserId),
);
}
if (typeof (req as any).markModified === "function") {
(req as any).markModified("expert");
}
} else {
const completedSteps = skipLocation
? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION]
: [WorkflowStep.SECOND_INITIAL_FORM];
this.pushWorkflowSteps(req, completedSteps);
req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION;
req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE;
}
}
private async createV3ClaimFromBlame(
req: any,
actor: any,
options: {
sheba?: string;
nationalCode?: string;
interimThirdParty?: boolean;
} = {},
) {
const requestId = String(req._id);
const existing = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (existing) {
throw new ConflictException(
"A claim for this blame file already exists.",
);
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const firstParty = req.parties?.[firstIdx];
const guiltyUserId = firstParty?.person?.userId;
const guiltyClientId = firstParty?.person?.clientId;
let ownerUserId: string;
let ownerRole: string | undefined;
let payingClientId: string | undefined;
if (req.type === BlameRequestType.CAR_BODY || !options.interimThirdParty) {
const ownerFields = resolveClaimOwnerFieldsFromBlame(req);
if (!ownerFields) {
throw new BadRequestException(
"Could not resolve claim owner from blame parties.",
);
}
ownerUserId = ownerFields.userId;
ownerRole = ownerFields.userRole;
payingClientId = ownerFields.clientId ?? guiltyClientId?.toString();
} else {
if (!guiltyUserId) {
throw new BadRequestException(
"First party must be verified before creating claim.",
);
}
ownerUserId = String(guiltyUserId);
ownerRole = PartyRole.FIRST;
payingClientId = guiltyClientId ? String(guiltyClientId) : undefined;
}
const claimNo = `CL${Math.floor(10000 + Math.random() * 90000)}`;
const newClaimData: any = {
requestNo: claimNo,
publicId: req.publicId,
blameRequestId: (req as any)._id,
blameRequestNo: req.requestNo,
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
claimStatus: ClaimStatus.PENDING,
inquiries: (req as any).inquiries ?? {},
workflow: {
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
...(options.interimThirdParty
? {}
: { damagedPartyUserId: new Types.ObjectId(ownerUserId) }),
owner: {
userId: new Types.ObjectId(ownerUserId),
userRole: ownerRole as any,
...(payingClientId
? {
clientId: new Types.ObjectId(payingClientId),
userClientKey: payingClientId,
}
: {}),
},
...(options.sheba
? {
money: {
sheba: options.sheba,
nationalCodeOfInsurer: options.nationalCode ?? "",
},
}
: {}),
// V5 (isMadeByFileMaker): mark approval gate at claim creation so the flag
// is always present regardless of whether upload-video is called.
...((req as any).isMadeByFileMaker
? {
requiresFileMakerApproval: true,
fileMakerApprovalActorId: new Types.ObjectId(actor.sub),
}
: {}),
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
timestamp: new Date(),
metadata: {
blameRequestId: requestId,
blamePublicId: req.publicId,
createdByV3InPerson: true,
interimThirdParty: !!options.interimThirdParty,
},
},
],
};
return this.claimCaseDbService.create(newClaimData);
}
private ensureV3GuiltyPartyDecision(req: any): void {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
if (!guiltyUserId) return;
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
(req.expert as any).decision.guiltyPartyId = new Types.ObjectId(
String(guiltyUserId),
);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("expert");
}
}
/** THIRD_PARTY: damaged party is always SECOND after OTP + inquiries. */
private resolveV3DamagedPartyForClaimSync(req: any): {
userId: string;
userRole?: string;
clientId?: string;
} | null {
if (req.type === BlameRequestType.CAR_BODY) {
return resolveClaimOwnerFieldsFromBlame(req);
}
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1) return null;
const damagedPerson = req.parties[secondIdx]?.person;
if (damagedPerson?.userId == null) return null;
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const guiltyPerson = firstIdx !== -1 ? req.parties[firstIdx]?.person : null;
const damagedUserId = String(damagedPerson.userId);
const guiltyUserId = guiltyPerson?.userId
? String(guiltyPerson.userId)
: null;
// Throws — caller should not catch; distinct accounts are required.
if (guiltyUserId && damagedUserId === guiltyUserId) {
throw new BadRequestException(
"Guilty and damaged parties are bound to the same user account. " +
"Re-verify the second party OTP using a different phone number.",
);
}
const payingClientId =
guiltyPerson?.clientId != null
? String(guiltyPerson.clientId)
: undefined;
return {
userId: damagedUserId,
userRole: PartyRole.SECOND,
...(payingClientId ? { clientId: payingClientId } : {}),
};
}
private async syncV3ClaimDamagedPartyFromBlame(
req: any,
partyData: RunInquiriesV3Dto,
): Promise<void> {
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (!claim) {
throw new NotFoundException("Claim not found for this blame file.");
}
this.ensureV3GuiltyPartyDecision(req);
const ownerFields = this.resolveV3DamagedPartyForClaimSync(req);
if (!ownerFields) {
throw new BadRequestException(
"Could not resolve damaged party after second-party inquiries. " +
"Ensure the second party OTP is verified and inquiry data was saved.",
);
}
const nationalCode =
partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver || "";
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
$set: {
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
"owner.userId": new Types.ObjectId(ownerFields.userId),
"owner.userRole": ownerFields.userRole,
...(ownerFields.clientId
? {
"owner.clientId": new Types.ObjectId(ownerFields.clientId),
"owner.userClientKey": ownerFields.clientId,
}
: {}),
...(partyData.sheba
? {
"money.sheba": partyData.sheba,
"money.nationalCodeOfInsurer": nationalCode,
}
: {}),
},
});
}
/**
* Unified inquiries: 1st call = guilty party (+ auto claim), 2nd call = damaged (THIRD_PARTY).
* Uses standard workflow steps (FIRST_INITIAL_FORM / SECOND_INITIAL_FORM).
*/
async runInquiriesV3(
requestId: string,
actor: any,
dto: RunInquiriesV3Dto,
): Promise<{
blameRequestId: string;
partyRole: PartyRole;
claimRequestId?: string;
claimPublicId?: string;
message: string;
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, actor);
const partyRole = this.resolveV3InquiryPartyRole(req);
if (!partyRole) {
throw new ConflictException("All party inquiries are already complete.");
}
if (partyRole === PartyRole.FIRST) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
if (!firstParty?.person?.userId) {
throw new BadRequestException(
"Guilty party OTP must be verified before running inquiries.",
);
}
if (req.type === BlameRequestType.CAR_BODY) {
const form = firstParty.carBodyFirstForm as any;
if (form?.car == null && form?.object == null) {
throw new BadRequestException(
"Submit car-body-form before running inquiries (CAR_BODY).",
);
}
}
await this.runPartyInquiriesV3Internal(
req,
dto,
PartyRole.FIRST,
firstParty,
);
if (req.type === BlameRequestType.CAR_BODY) {
await this.validateShebaV3(
dto,
firstParty.person?.clientId?.toString(),
);
}
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor);
await (req as any).save();
const nationalCode =
dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || "";
const newClaim = await this.createV3ClaimFromBlame(req, actor, {
sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined,
nationalCode:
req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined,
interimThirdParty: req.type === BlameRequestType.THIRD_PARTY,
});
return {
blameRequestId: requestId,
partyRole: PartyRole.FIRST,
claimRequestId: String(newClaim._id),
claimPublicId: req.publicId,
message:
"Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.",
};
}
// Damaged party (THIRD_PARTY only)
if (!this.partyHasSigned(req, PartyRole.FIRST)) {
throw new BadRequestException(
"Guilty party must sign before running damaged-party inquiries.",
);
}
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) {
throw new BadRequestException(
"Damaged party OTP must be verified before running inquiries.",
);
}
const secondParty = req.parties[secondIdx];
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const firstUserId =
firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null;
const secondUserId = secondParty.person?.userId;
if (
firstUserId &&
secondUserId &&
String(firstUserId) === String(secondUserId)
) {
throw new BadRequestException(
"Guilty and damaged parties are bound to the same user account. " +
"Re-verify the second party OTP using a different phone number.",
);
}
await this.runPartyInquiriesV3Internal(
req,
dto,
PartyRole.SECOND,
secondParty,
);
await this.validateShebaV3(dto, secondParty.person?.clientId?.toString());
this.ensureV3GuiltyPartyDecision(req);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("parties");
}
await (req as any).save();
await this.syncV3ClaimDamagedPartyFromBlame(req, dto);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor);
await (req as any).save();
return {
blameRequestId: requestId,
partyRole: PartyRole.SECOND,
message:
"Damaged-party inquiries complete. Proceed with location, description, voice, and signature.",
};
}
private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void {
this.assertBlameV3ExpertInPerson(req);
if (partyRole === PartyRole.FIRST) {
if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) {
throw new BadRequestException(
"Complete guilty-party inquiries before collecting details.",
);
}
if (this.partyHasSigned(req, PartyRole.FIRST)) {
throw new BadRequestException("Guilty party has already signed.");
}
return;
}
if (req.type !== BlameRequestType.THIRD_PARTY) {
throw new BadRequestException("CAR_BODY has only one party.");
}
if (!this.isV3SecondPartyDetailPhase(req)) {
throw new BadRequestException(
"Complete damaged-party inquiries before collecting details.",
);
}
if (this.partyHasSigned(req, PartyRole.SECOND)) {
throw new BadRequestException("Damaged party has already signed.");
}
}
private assertBlameV3AccidentFieldsPhase(req: any): void {
this.assertBlameV3ExpertInPerson(req);
const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
const signed = (req.parties ?? []).filter(
(p: any) => p?.confirmation != null,
);
if (signed.length < requiredSigs) {
throw new BadRequestException(
"All required party signatures must be collected before accident fields.",
);
}
}
/**
* V3 signature — no accident-fields prerequisite; does not auto-complete blame
* until finalize-for-field is called after part selection.
*/
async expertUploadPartySignatureV3(
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");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
if (partyRole === PartyRole.FIRST) {
if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) {
throw new BadRequestException(
"Complete guilty-party inquiries before signing.",
);
}
if (this.partyHasSigned(req, PartyRole.FIRST)) {
throw new BadRequestException("Guilty party has already signed.");
}
if (this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) {
throw new BadRequestException(
"Guilty party must sign before damaged-party inquiries.",
);
}
} else {
if (!this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) {
throw new BadRequestException(
"Complete damaged-party inquiries before signing.",
);
}
}
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,
};
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
$set: {
[`parties.${partyIndex}.confirmation`]: {
partyRole: party.role,
accepted: isAccept,
signature: signatureData,
},
},
});
const fresh = await this.blameRequestDbService.findById(requestId);
if (fresh) {
const isFileMakerActor = expert?.role === RoleEnum.FILE_MAKER;
if (partyRole === PartyRole.FIRST) {
this.pushWorkflowSteps(fresh, [
WorkflowStep.FIRST_LOCATION,
WorkflowStep.FIRST_VOICE,
WorkflowStep.FIRST_DESCRIPTION,
WorkflowStep.FIRST_SIGN,
WorkflowStep.FIRST_COMPLETED,
]);
if (fresh.type === BlameRequestType.THIRD_PARTY) {
fresh.workflow.currentStep = WorkflowStep.FIRST_INVITE_SECOND;
fresh.workflow.nextStep = WorkflowStep.SECOND_INITIAL_FORM;
} else {
// CAR_BODY: FileMaker is done — seal the file for FileReviewer pickup
fresh.workflow.currentStep = WorkflowStep.FIRST_COMPLETED;
fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
if (isFileMakerActor) {
fresh.status = CaseStatus.WAITING_FOR_FILE_REVIEWER;
}
}
} else {
this.pushWorkflowSteps(fresh, [
WorkflowStep.SECOND_LOCATION,
WorkflowStep.SECOND_VOICE,
WorkflowStep.SECOND_DESCRIPTION,
WorkflowStep.SECOND_SIGN,
WorkflowStep.SECOND_COMPLETED,
]);
fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED;
fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
// THIRD_PARTY: second party sign is FileMaker's last step — seal for FileReviewer
if (isFileMakerActor) {
fresh.status = CaseStatus.WAITING_FOR_FILE_REVIEWER;
}
}
await (fresh as any).save();
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_PARTY_SIGNATURE_RECORDED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { partyRole, accepted: isAccept },
} as any);
await (req as any).save();
return {
requestId,
accepted: isAccept,
status: req.status ?? CaseStatus.OPEN,
message: `Signature recorded for ${partyRole} party.`,
};
}
/** Saves accident fields without advancing blame to WAITING_FOR_SIGNATURES (V3 order). */
async expertAddAccidentFieldsForBlameV3(
expert: any,
requestId: string,
fields: ExpertAccidentFieldsDto,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
this.assertBlameV3AccidentFieldsPhase(req);
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
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,
},
};
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_ACCIDENT_FIELDS_SAVED",
actor: {
actorId: new Types.ObjectId(String(expert.sub)),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { accidentWay: fields.accidentWay },
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
message:
"Accident fields saved. Proceed to required documents, part selection, capture, walk-around video, then blame accident video.",
};
}
/**
* V3 final field step: blame accident video. Moves file to WAITING_FOR_EXPERT
* after claim capture and walk-around video are complete.
*/
async expertUploadBlameVideoV3(
expert: any,
requestId: string,
file: Express.Multer.File | undefined,
): Promise<{
requestId: string;
publicId: string;
videoId: string | undefined;
status: string;
message: string;
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
// Idempotent: if blame is already completed (car-capture auto-completed it
// for V4/V5, or a previous call already ran), return success immediately.
if ((req as any).status === CaseStatus.COMPLETED) {
return {
requestId: String(req._id),
publicId: req.publicId,
videoId: undefined,
status: (req as any).status,
message: "File already completed.",
};
}
if (!(req.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before uploading the blame accident video.",
);
}
const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
const signed = (req.parties ?? []).filter(
(p: any) => p?.confirmation != null,
);
if (signed.length < requiredSigs) {
throw new BadRequestException(
"All required party signatures must be collected before the blame accident video.",
);
}
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (!claim) {
throw new BadRequestException(
"Claim not found. Complete guilty-party inquiries first.",
);
}
if (!claim.media?.videoCaptureId) {
throw new BadRequestException(
"Upload the claim walk-around video (car-capture) before the blame accident video.",
);
}
if (
claim.workflow?.currentStep !==
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
throw new BadRequestException(
"Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.",
);
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
// Store video if provided; skip silently for V4/V5 where it is optional.
let videoId: string | undefined;
if (file) {
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Blame accident video already uploaded");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
videoId = firstParty.evidence.videoId;
}
this.pushWorkflowSteps(req, [
WorkflowStep.FIRST_VIDEO,
WorkflowStep.WAITING_FOR_GUILT_DECISION,
]);
req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.status = CaseStatus.COMPLETED;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { videoId: videoId ?? null },
} as any);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("parties");
}
await (req as any).save();
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
$set: {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
history: {
type: "STEP_COMPLETED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName:
`${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description:
"V3 field workflow complete. Claim ready for damage expert review.",
v3InPersonFlow: true,
},
},
},
});
return {
requestId: String(req._id),
publicId: req.publicId,
videoId,
status: req.status,
message: file
? "Blame accident video uploaded. File is now waiting for expert review."
: "File completed. Claim is now ready for damage expert review.",
};
}
/**
* @deprecated V3 blame completes via expertUploadBlameVideoV3 (WAITING_FOR_EXPERT).
*/
async tryAutoCompleteBlameV3ForField(_req: any): Promise<void> {
return;
}
private partyHasInquiryData(req: any, role: PartyRole): boolean {
const roleKey = role === PartyRole.FIRST ? "FIRST" : "SECOND";
if (req.inquiries?.thirdParty?.data?.[roleKey]) return true;
const idx = this.getPartyIndex(req, role);
return idx !== -1 && req.parties?.[idx]?.vehicle?.inquiry != null;
}
private isV3SecondPartyDetailPhase(req: any): boolean {
if (req.type !== BlameRequestType.THIRD_PARTY) return false;
if (!this.partyHasSigned(req, PartyRole.FIRST)) return false;
const step = String(req.workflow?.currentStep ?? "");
if (step.startsWith("SECOND_")) return true;
return (
this.hasPartyInquiriesComplete(req, PartyRole.SECOND) ||
this.partyHasInquiryData(req, PartyRole.SECOND)
);
}
/** When partyRole is omitted, infer from workflow / party progress (sequential V3 flow). */
private resolvePartyRoleV3(req: any, partyRole?: string): PartyRole {
if (partyRole === "SECOND" || partyRole === PartyRole.SECOND) {
return PartyRole.SECOND;
}
if (partyRole === "FIRST" || partyRole === PartyRole.FIRST) {
return PartyRole.FIRST;
}
const currentStep = String(req.workflow?.currentStep ?? "");
if (currentStep.startsWith("SECOND_")) return PartyRole.SECOND;
if (currentStep.startsWith("FIRST_")) return PartyRole.FIRST;
if (
this.hasPartyInquiriesComplete(req, PartyRole.FIRST) &&
!this.partyHasSigned(req, PartyRole.FIRST)
) {
return PartyRole.FIRST;
}
if (
req.type === BlameRequestType.THIRD_PARTY &&
this.partyHasSigned(req, PartyRole.FIRST) &&
!this.partyHasSigned(req, PartyRole.SECOND)
) {
return PartyRole.SECOND;
}
return PartyRole.FIRST;
}
async addPartyVoiceV3(
requestId: string,
actor: any,
voice: Express.Multer.File,
partyRole?: string,
) {
if (!voice) throw new BadRequestException("File is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
await this.verifyExpertAccessForBlameV2(req, actor);
const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
const voiceDoc = await this.blameVoiceDbService.create({
fileName: voice.filename,
path: voice.path,
requestId: new Types.ObjectId(req._id),
context: UploadContext.INITIAL,
} as any);
const party = req.parties[idx];
if (!party.evidence) party.evidence = {} as any;
if (!Array.isArray(party.evidence.voices)) party.evidence.voices = [];
party.evidence.voices.push(String((voiceDoc as any)._id));
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_PARTY_VOICE_UPLOADED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { partyRole: role, voiceId: String((voiceDoc as any)._id) },
} as any);
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
async addPartyLocationV3(
requestId: string,
actor: any,
body: LocationDto,
partyRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
await this.verifyExpertAccessForBlameV2(req, actor);
// V4/V5: location step is skipped — store whatever is sent but do not
// validate against workflow phase (the step was auto-completed at inquiry time).
if ((req as any).isMadeByFileMaker) {
const role = this.resolvePartyRoleV3(req, partyRole);
const idx = this.getPartyIndex(req, role);
if (idx !== -1 && body) req.parties[idx].location = body as any;
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
req.parties[idx].location = body as any;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_PARTY_LOCATION_SAVED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { partyRole: role, location: body },
} as any);
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
async addPartyDescriptionV3(
requestId: string,
actor: any,
body: DescriptionDto,
partyRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
await this.verifyExpertAccessForBlameV2(req, actor);
const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role);
const idx = this.getPartyIndex(req, role);
if (idx === -1) throw new BadRequestException(`${role} party not found`);
const party = req.parties[idx];
if (!party.statement) party.statement = {} as any;
party.statement.description = body.desc;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_PARTY_DESCRIPTION_SAVED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { partyRole: role },
} as any);
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
async carBodyAccidentTypeFormV3(
requestId: string,
body: CarBodyFormDto,
actor: any,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException(
"CAR_BODY accident type is only for CAR_BODY files.",
);
}
await this.verifyExpertAccessForBlameV2(req, actor);
this.assertBlameV3ExpertInPerson(req);
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
if (!firstParty?.person?.userId) {
throw new BadRequestException(
"Party OTP must be verified before the car-body form.",
);
}
if (this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) {
throw new BadRequestException(
"Car-body form must be submitted before running inquiries.",
);
}
if (!firstParty.carBodyFirstForm) firstParty.carBodyFirstForm = {} as any;
firstParty.carBodyFirstForm.car = body.car;
firstParty.carBodyFirstForm.object = body.object;
this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]);
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_CAR_BODY_ACCIDENT_TYPE_SAVED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { car: body.car, object: body.object },
} as any);
await (req as any).save();
return { requestId: req._id, publicId: req.publicId };
}
/**
* V5 variant of expertUploadBlameVideoV3.
* Same flow as V3/V4 but additionally marks the linked claim with
* `requiresFileMakerApproval: true` so that after the owner signs,
* the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL rather than
* being auto-submitted to fanavaran.
*/
async expertUploadBlameVideoV5(
expert: any,
requestId: string,
file: Express.Multer.File | undefined,
): Promise<{
requestId: string;
publicId: string;
videoId: string | undefined;
status: string;
message: string;
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
// Idempotent: blame already completed (car-capture auto-completed it for
// V4/V5, or a previous call already ran).
if ((req as any).status === CaseStatus.COMPLETED) {
return {
requestId: String(req._id),
publicId: req.publicId,
videoId: undefined,
status: (req as any).status,
message: "File already completed.",
};
}
if (!(req.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before uploading the blame accident video.",
);
}
const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
const signed = (req.parties ?? []).filter(
(p: any) => p?.confirmation != null,
);
if (signed.length < requiredSigs) {
throw new BadRequestException(
"All required party signatures must be collected before the blame accident video.",
);
}
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (!claim) {
throw new BadRequestException(
"Claim not found. Complete guilty-party inquiries first.",
);
}
if (!claim.media?.videoCaptureId) {
throw new BadRequestException(
"Upload the claim walk-around video (car-capture) before the blame accident video.",
);
}
if (
claim.workflow?.currentStep !==
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
throw new BadRequestException(
"Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.",
);
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
// Store video if provided; skip silently for V4/V5 where it is optional.
let videoId: string | undefined;
if (file) {
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Blame accident video already uploaded");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
videoId = firstParty.evidence.videoId;
}
this.pushWorkflowSteps(req, [
WorkflowStep.FIRST_VIDEO,
WorkflowStep.WAITING_FOR_GUILT_DECISION,
]);
req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.status = CaseStatus.COMPLETED;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "file_reviewer",
},
metadata: { videoId: videoId ?? null },
} as any);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("parties");
}
await (req as any).save();
// Advance the claim into the damage-expert queue.
// requiresFileMakerApproval / fileMakerApprovalActorId were already set at
// claim creation time (createV3ClaimFromBlame) so no need to repeat here.
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
$set: {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
history: {
type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "file_reviewer",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description:
"V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).",
v5InPersonFlow: true,
},
},
},
});
return {
requestId: String(req._id),
publicId: req.publicId,
videoId,
status: req.status,
message: file
? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission."
: "File completed. Claim is now ready for damage expert review.",
};
}
/**
* V5: FileMaker approves the completed claim.
*
* Preconditions:
* - claim.requiresFileMakerApproval === true
* - claim.status === WAITING_FOR_FILE_MAKER_APPROVAL
* - actor is FILE_MAKER and is the original creator of the linked blame file
*
* On approval: triggers fanavaran submission and moves claim to COMPLETED.
*/
async fileMakerApproveV5(
fileMaker: any,
claimRequestId: string,
): Promise<{
claimRequestId: string;
publicId: string;
status: string;
message: string;
}> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) throw new NotFoundException("Claim not found");
this.assertFileMakerApprovalAccess(claim, fileMaker);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) {
throw new BadRequestException(
`Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`,
);
}
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
const historyEntry = {
type: "V5_FILE_MAKER_APPROVED",
actor: {
actorId: new Types.ObjectId(fileMaker.sub),
actorName,
actorType: "file_maker",
},
timestamp: new Date(),
metadata: {},
};
// Move claim to COMPLETED and clear the approval gate
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
requiresFileMakerApproval: false,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
history: historyEntry,
},
});
return {
claimRequestId,
publicId: claim.publicId,
status: ClaimCaseStatus.COMPLETED,
message:
"Claim approved by FileMaker. Claim is now marked COMPLETED. " +
"Proceed with fanavaran submission via the claim service.",
};
}
/**
* V5: FileMaker rejects the completed claim back to expert review.
*
* Moves claim from WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT
* so the FileReviewer can re-lock it and adjust the damage assessment or
* restart the back-and-forth with the user.
*/
async fileMakerRejectV5(
fileMaker: any,
claimRequestId: string,
reason?: string,
): Promise<{
claimRequestId: string;
publicId: string;
status: string;
message: string;
}> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) throw new NotFoundException("Claim not found");
this.assertFileMakerApprovalAccess(claim, fileMaker);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) {
throw new BadRequestException(
`Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to reject. Current: ${claim.status}`,
);
}
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.FILE_MAKER_REJECTED,
claimStatus: ClaimStatus.NEEDS_REVISION,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
},
$push: {
history: {
type: "V5_FILE_MAKER_REJECTED",
actor: {
actorId: new Types.ObjectId(fileMaker.sub),
actorName,
actorType: "file_maker",
},
timestamp: new Date(),
metadata: { reason: reason ?? null },
},
},
});
return {
claimRequestId,
publicId: claim.publicId,
status: ClaimCaseStatus.FILE_MAKER_REJECTED,
message:
"Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.",
};
}
/** Guard for V5 FileMaker approval endpoints. */
private assertFileMakerApprovalAccess(claim: any, fileMaker: any): void {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can perform this action.");
}
if (!claim.requiresFileMakerApproval) {
throw new ForbiddenException(
"This claim does not require FileMaker approval (not a V5 file).",
);
}
// Scope to the FileMaker who created the linked blame file
if (
claim.fileMakerApprovalActorId &&
String(claim.fileMakerApprovalActorId) !== String(fileMaker.sub)
) {
throw new ForbiddenException(
"Only the FileMaker who created this file can approve or reject it.",
);
}
}
}