Files
yara724api/src/request-management/request-management.service.ts
2026-06-15 15:58:31 +03:30

7760 lines
256 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,
} 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 { 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 { 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";
@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;
const partyUserId = party?.person?.userId
? String(party.person.userId)
: null;
const ok =
(partyUserId && partyUserId === String(user?.sub)) ||
(party?.person?.phoneNumber &&
party.person.phoneNumber === user?.username);
if (!ok) throw new ForbiddenException(errMsg);
}
/**
* For the on-behalf (expert/registrar) flow only: when the caller explicitly
* labels which party a submission belongs to (`partyRole`), make sure it
* matches the party the file is currently collecting data for. Keeps the
* sequential workflow intact while guaranteeing the expert never attributes a
* submission to the wrong party. No-op for normal users (who never pass it).
*/
private assertExpectedPartyRole(
expectedRole: string | undefined,
actualRole: PartyRole,
onBehalf: boolean,
) {
if (!onBehalf || expectedRole == null || expectedRole === "") return;
const want =
String(expectedRole).toUpperCase() === "SECOND"
? PartyRole.SECOND
: PartyRole.FIRST;
if (want !== actualRole) {
throw new BadRequestException(
`partyRole mismatch: this file is currently collecting the ${actualRole} party's data, but partyRole=${want} was sent. Submit ${actualRole} party data (advance the file to the other party first if needed).`,
);
}
}
private async advanceWorkflowToNext(
req: any,
submittedStepKey: WorkflowStep,
) {
// 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;
}
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 = (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 = 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;
try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry({
plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
});
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.logger.log(
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
);
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 (req as any).save();
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 (req as any).save();
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,
);
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 (req as any).save();
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?.LastCompanyDocumentNumber;
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,
});
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 (req as any).save();
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
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,
companyCode,
companyName: clientName,
},
} as any);
await (req as any).save();
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;
}
// Send OTP to second party — expert collects it from them in person
try {
await this.userAuthService.sendOtpRequest(phoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`(${phoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
// Stay at FIRST_INVITE_SECOND — workflow will advance only after verifyPartyOtp
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "SECOND_PARTY_OTP_SENT",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
inPersonExpert: true,
phoneNumber,
note: "OTP sent; call verify-party-otp to bind and advance to SECOND_INITIAL_FORM",
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
message: `OTP sent to ${phoneNumber}. Collect the code and call verify-party-otp.`,
};
}
// async createRequest(user, type) {
// const reqNumber = new ShortUniqueId({ counter: 1 });
// const publicId = await this.publicIdService.generateRequestPublicId();
// 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,
},
property: {
smsApiKey: 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 verifyExpertAccessForBlameV2(req: any, expert: any): void {
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: {} }];
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties,
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
expertInitiated: true,
initiatedByFieldExpertId: expertId,
creationMethod: dto.creationMethod,
filledBy:
dto.creationMethod === CreationMethod.IN_PERSON
? FilledBy.EXPERT
: FilledBy.CUSTOMER,
});
const requestId = String((created as any)._id);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_FIELD_EXPERT",
actor: {
actorId: expertId,
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { creationMethod: dto.creationMethod, type: dto.type },
});
await (created as any).save();
const result: {
requestId: string;
publicId: string;
linkUrl?: string;
} = {
requestId,
publicId: (created as any).publicId,
};
if (dto.creationMethod === CreationMethod.LINK) {
const baseUrl =
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
result.linkUrl = baseUrl
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
: undefined;
}
return result;
}
/**
* 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.",
);
}
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 = 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.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (
req.type === BlameRequestType.THIRD_PARTY &&
!dto.secondPartyPhoneNumber
) {
throw new BadRequestException(
"Second party phone number is required for THIRD_PARTY. Provide secondPartyPhoneNumber to send OTP to both parties.",
);
}
const sent: string[] = [];
try {
await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber);
sent.push(dto.firstPartyPhoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
if (dto.secondPartyPhoneNumber) {
try {
await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber);
sent.push(dto.secondPartyPhoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
}
return {
sent: true,
message:
sent.length === 2
? `OTP sent to both parties (${sent.join(", ")}). Have them tell you the code, then call verify-party-otps.`
: `OTP sent to ${sent[0]}. Have the party tell you the code, then call verify-party-otps.`,
};
}
/**
* V2: Verify one or two party OTPs for expert-initiated IN_PERSON blame.
* Call this after send-party-otps (or after parties requested OTP via /user/send-otp). On success, parties are linked to their user ids so the expert can proceed with complete-blame-data.
*/
async verifyPartyOtpsV2(
expert: any,
requestId: string,
dto: {
firstPartyPhoneNumber: string;
firstPartyOtp: string;
secondPartyPhoneNumber?: string;
secondPartyOtp?: string;
},
): Promise<{ verified: boolean; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
const now = Date.now();
const verifyOne = async (
phone: string,
otp: string,
): Promise<Types.ObjectId> => {
const user = await this.userDbService.findOne({
$or: [{ username: phone }, { mobile: phone }],
});
if (!user)
throw new BadRequestException(`User not found for phone: ${phone}`);
const u = user as any;
if (u.otp == null)
throw new BadRequestException(
`No OTP requested for ${phone}. User must call /user/send-otp first.`,
);
if (u.otpExpire < now)
throw new BadRequestException(
`OTP expired for ${phone}. User must request a new OTP.`,
);
const valid = await this.hashService.compare(otp, u.otp);
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
return u._id;
};
const firstUserId = await verifyOne(
dto.firstPartyPhoneNumber,
dto.firstPartyOtp,
);
if (!Array.isArray(req.parties)) req.parties = [];
const firstIdx = req.parties.findIndex(
(p: any) => p?.role === PartyRole.FIRST,
);
if (firstIdx === -1)
throw new BadRequestException("First party not found on request");
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = firstUserId;
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber;
if (
req.type === BlameRequestType.THIRD_PARTY &&
dto.secondPartyPhoneNumber &&
dto.secondPartyOtp
) {
const secondUserId = await verifyOne(
dto.secondPartyPhoneNumber,
dto.secondPartyOtp,
);
let secondIdx = req.parties.findIndex(
(p: any) => p?.role === PartyRole.SECOND,
);
if (secondIdx === -1) {
req.parties.push({
role: PartyRole.SECOND,
person: {
userId: secondUserId,
phoneNumber: dto.secondPartyPhoneNumber,
},
} as any);
} else {
if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = secondUserId;
req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber;
}
} else if (
req.type === BlameRequestType.THIRD_PARTY &&
(dto.secondPartyPhoneNumber || dto.secondPartyOtp)
) {
throw new BadRequestException(
"For THIRD_PARTY both secondPartyPhoneNumber and secondPartyOtp are required.",
);
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "PARTY_OTPS_VERIFIED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType:
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.",
);
}
this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required");
try {
await this.userAuthService.sendOtpRequest(phone);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`(${phone}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
return {
sent: true,
message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`,
};
}
/**
* Verify a single party OTP and bind that party's user account.
* `partyRole` is optional: when omitted, FIRST is bound if it has no user yet,
* otherwise SECOND (created if missing). For THIRD_PARTY the second party is
* registered here instead of receiving an invite link.
*/
async verifyPartyOtpV2(
actor: any,
requestId: string,
dto: { phoneNumber: string; otp: string; partyRole?: string },
): Promise<{ verified: boolean; partyRole: PartyRole; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim();
const otp = (dto?.otp || "").trim();
if (!phone || !otp) {
throw new BadRequestException("phoneNumber and otp are required");
}
if (!Array.isArray(req.parties)) req.parties = [];
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
// Resolve which party this phone belongs to.
let role: PartyRole;
if (dto.partyRole === "SECOND" || dto.partyRole === "FIRST") {
role = dto.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
} else {
const firstHasUser = !!(
firstIdx !== -1 && req.parties[firstIdx]?.person?.userId
);
role = firstHasUser ? PartyRole.SECOND : PartyRole.FIRST;
}
if (
role === PartyRole.SECOND &&
req.type !== BlameRequestType.THIRD_PARTY
) {
throw new BadRequestException("CAR_BODY has only one (first) party.");
}
// Verify the OTP and resolve the user id.
const now = Date.now();
const user = await this.userDbService.findOne({
$or: [{ username: phone }, { mobile: phone }],
});
if (!user)
throw new BadRequestException(`User not found for phone: ${phone}`);
const u = user as any;
if (u.otp == null)
throw new BadRequestException(
`No OTP requested for ${phone}. Send an OTP first.`,
);
if (u.otpExpire < now)
throw new BadRequestException(
`OTP expired for ${phone}. Request a new OTP.`,
);
const valid = await this.hashService.compare(otp, u.otp);
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
const userId = u._id as Types.ObjectId;
if (role === PartyRole.FIRST) {
if (firstIdx === -1)
throw new BadRequestException("First party not found on request");
if (!req.parties[firstIdx].person)
req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = userId;
req.parties[firstIdx].person.phoneNumber = phone;
} else {
const firstPhone =
firstIdx !== -1
? (req.parties[firstIdx]?.person as any)?.phoneNumber
: undefined;
if (firstPhone && firstPhone === phone) {
throw new BadRequestException(
"Second party phone number cannot be the same as first party.",
);
}
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1) {
req.parties.push({
role: PartyRole.SECOND,
person: { userId, phoneNumber: phone },
} as any);
} else {
if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = userId;
req.parties[secondIdx].person.phoneNumber = phone;
}
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "PARTY_OTP_VERIFIED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: { partyRole: role, phoneNumber: phone },
} as any);
// For IN_PERSON expert/registrar flow: after first party OTP is verified and
// the workflow is still at CREATED, advance past any intro step to the first
// real data-entry step.
//
// THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and
// land on FIRST_VIDEO.
// CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE
// so the expert can fill the car-body form before video.
if (
role === PartyRole.FIRST &&
req.workflow?.currentStep === WorkflowStep.CREATED
) {
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (req.type === BlameRequestType.CAR_BODY) {
// Advance CREATED → CAR_BODY_ACCIDENT_TYPE
// nextStep was already set to CAR_BODY_ACCIDENT_TYPE at creation time;
// look up its own nextPossibleSteps so we can set nextStep correctly.
const carBodyStepDoc = await this.getWorkflowStep({
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
});
const afterCarBody =
(carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ??
WorkflowStep.FIRST_VIDEO;
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
req.workflow.nextStep = afterCarBody;
req.history.push({
type: "AUTO_ADVANCED_TO_CAR_BODY_FORM",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE },
} as any);
} else {
// THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON
const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION
const step2Key = step2.stepKey as WorkflowStep;
const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO
const step3Key = step3.stepKey as WorkflowStep;
const nextAfterVideo =
(step3.nextPossibleSteps?.[0] as WorkflowStep) ??
WorkflowStep.FIRST_INITIAL_FORM;
if (!completed.includes(step2Key)) completed.push(step2Key);
req.workflow.completedSteps = completed;
req.workflow.currentStep = step3Key;
req.workflow.nextStep = nextAfterVideo;
// Auto-guilt: first party is always guilty in expert-initiated IN_PERSON
const fIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (fIdx !== -1) {
if (!req.parties[fIdx].statement)
req.parties[fIdx].statement = {} as any;
req.parties[fIdx].statement.admitsGuilt = true;
req.parties[fIdx].statement.claimsDamage = false;
req.parties[fIdx].statement.acceptsExpertOpinion = false;
}
req.blameStatus = BlameStatus.AGREED;
req.history.push({
type: "AUTO_CONFESSION_SKIPPED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
reason:
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved",
stepKey: step2Key,
advancedTo: step3Key,
},
} as any);
}
}
// For IN_PERSON expert/registrar flow: after second party OTP is verified and
// the workflow is at FIRST_INVITE_SECOND, advance to SECOND_INITIAL_FORM.
if (
role === PartyRole.SECOND &&
req.workflow?.currentStep === WorkflowStep.FIRST_INVITE_SECOND
) {
await this.advanceWorkflowToNext(req, WorkflowStep.FIRST_INVITE_SECOND);
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
req.history.push({
type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
phoneNumber: phone,
advancedTo: WorkflowStep.SECOND_INITIAL_FORM,
},
} as any);
}
await (req as any).save();
return {
verified: true,
partyRole: role,
message: `${role} party verified and bound to phone ${phone}.`,
};
}
/**
* List all expert-initiated blame files for the current field expert (their panel).
* Only files where initiatedBy === current user are returned.
*/
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 userIdFilter =
user?.sub && Types.ObjectId.isValid(user.sub)
? { "parties.person.userId": new Types.ObjectId(user.sub) }
: null;
const phoneFilter = user?.username
? { "parties.person.phoneNumber": user.username }
: null;
const filters = [userIdFilter, phoneFilter].filter(Boolean);
if (filters.length === 0) {
return { list: [], total: 0 };
}
const requests = await this.blameRequestDbService.find(
filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any),
{
select:
"publicId requestNo type status blameStatus createdAt updatedAt parties",
},
);
const enriched = requests.map((req: any) => {
const party = req.parties.find(
(p: any) =>
(p?.person?.userId &&
String(p.person.userId) === String(user.sub)) ||
(p?.person?.phoneNumber && p.person.phoneNumber === user?.username),
);
const obj = req.toObject();
delete obj.parties; // remove parties completely
return {
...obj,
userSide: party?.role ?? null,
};
});
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 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) => {
const pid = p?.person?.userId ? String(p.person.userId) : null;
const phone = p?.person?.phoneNumber;
return (
(pid && pid === String(user?.sub)) ||
(phone && phone === user?.username)
);
});
if (!isFieldExpertOwner && !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;
for (const p of req.parties || []) {
if (p?.person?.phoneNumber === user?.username && !p.person?.userId) {
p.person = p.person || {};
(p.person as any).userId = new Types.ObjectId(user.sub);
updated = true;
}
}
if (updated) {
await (req as any).save();
}
}
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) => {
const pid = p?.person?.userId ? String(p.person.userId) : null;
const phone = p?.person?.phoneNumber;
return (
(pid && pid === String(user?.sub)) ||
(phone && phone === user?.username)
);
});
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.",
);
}
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.");
}
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: formData.firstPartyPhoneNumber,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
insurerLicense: firstPartyPlate.insurerLicense,
driverLicense: firstPartyPlate.driverLicense,
driverIsInsurer: firstPartyPlate.driverIsInsurer,
isNewCar: firstPartyPlate.isNewCar,
userNoCertificate: firstPartyPlate.userNoCertificate,
insurerBirthday: firstPartyPlate.insurerBirthday,
driverBirthday: firstPartyPlate.driverBirthday,
},
vehicle: {
plateId:
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.");
}
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}`,
);
}
return {
role,
person: {
userId,
phoneNumber,
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
insurerLicense: plateDto.insurerLicense,
driverLicense: plateDto.driverLicense,
driverIsInsurer: plateDto.driverIsInsurer,
isNewCar: plateDto.isNewCar,
userNoCertificate: plateDto.userNoCertificate,
insurerBirthday: plateDto.insurerBirthday,
driverBirthday: plateDto.driverBirthday,
},
vehicle: {
plateId:
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.",
);
}
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.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Video already uploaded for this file");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
await (req as any).save();
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "EXPERT_UPLOADED_VIDEO_V2",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { videoId: firstParty.evidence.videoId },
} as any);
await (req as any).save();
return { requestId: req._id, videoId: firstParty.evidence.videoId };
}
/**
* V2: Expert uploads voice for expert-initiated BlameRequest (first party).
*/
async expertUploadVoiceForBlameV2(
expert: any,
requestId: string,
voiceFile: Express.Multer.File,
): Promise<any> {
if (!voiceFile) throw new BadRequestException("Voice file is required");
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1)
throw new BadRequestException("First party not found");
const firstParty = req.parties[firstPartyIndex];
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.",
);
}
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) {
throw new BadRequestException(
"This endpoint is only for expert-initiated files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
// 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> {
// Try to find existing user by phone number (username or mobile)
let user = await this.userDbService.findOne({
$or: [{ username: phoneNumber }, { mobile: phoneNumber }],
});
if (user) {
return new Types.ObjectId((user as any)._id);
}
// User doesn't exist, create a new one
const newUser = await this.userDbService.createUser({
mobile: phoneNumber,
username: phoneNumber,
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,
};
}
}