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