forked from Yara724/api
5792 lines
194 KiB
TypeScript
5792 lines
194 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 ShortUniqueId from "short-unique-id";
|
||
import { ClientService } from "src/client/client.service";
|
||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||
import {
|
||
CarBodyFormDto,
|
||
CarBodySecondForm,
|
||
InitialFormDto,
|
||
} from "src/request-management/dto/create-request-management.dto";
|
||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||
import { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto";
|
||
import { UserSignatureResponseDto } from "./dto/user-signature.dto";
|
||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
||
import { 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 { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.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 { UploadContext } from "./entities/schema/blame-voice.schema";
|
||
import { HashService } from "src/utils/hash/hash.service";
|
||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||
|
||
@Injectable()
|
||
export class RequestManagementService {
|
||
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 assertPartyOwner(party: any, user: any, errMsg: string) {
|
||
const partyUserId = party?.person?.userId ? String(party.person.userId) : null;
|
||
const ok =
|
||
(partyUserId && partyUserId === String(user?.sub)) ||
|
||
(party?.person?.phoneNumber && party.person.phoneNumber === user?.username);
|
||
if (!ok) throw new ForbiddenException(errMsg);
|
||
}
|
||
|
||
private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) {
|
||
// CAR_BODY: after first-party description there is no second party or expert; go to COMPLETED
|
||
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.COMPLETED;
|
||
req.workflow.nextStep = undefined;
|
||
req.status = CaseStatus.COMPLETED;
|
||
this.logger.debug(
|
||
"[WORKFLOW] CAR_BODY: advanced to COMPLETED after FIRST_DESCRIPTION",
|
||
);
|
||
return;
|
||
}
|
||
|
||
const submittedStep = await this.getWorkflowStep({ stepKey: submittedStepKey });
|
||
|
||
this.logger.debug(
|
||
`[WORKFLOW] Advancing from ${submittedStepKey} (stepNumber=${submittedStep.stepNumber})`,
|
||
);
|
||
|
||
// completedSteps includes the step that was just submitted
|
||
const completed = Array.isArray(req.workflow.completedSteps)
|
||
? req.workflow.completedSteps
|
||
: [];
|
||
if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey);
|
||
req.workflow.completedSteps = completed;
|
||
|
||
// Prefer sequential stepNumber, fallback to nextPossibleSteps for branching/end-of-flow
|
||
const sequentialNextStep = await this.workflowStepDbService.findByStepNumber(
|
||
submittedStep.stepNumber + 1,
|
||
);
|
||
|
||
const nextStepKey = (sequentialNextStep?.stepKey ??
|
||
submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined;
|
||
|
||
if (!nextStepKey) {
|
||
// Terminal state: no more steps
|
||
req.workflow.currentStep = submittedStepKey;
|
||
req.workflow.nextStep = undefined;
|
||
this.logger.debug(
|
||
`[WORKFLOW] Reached terminal state at ${submittedStepKey}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Try to get the next step doc; if it doesn't exist in DB, it's a "waiting state" (valid enum but not actionable)
|
||
let nextStepDoc: WorkflowStepModel | null = null;
|
||
try {
|
||
nextStepDoc = sequentialNextStep ?? (await this.workflowStepDbService.findByStepKey(nextStepKey));
|
||
} catch (err) {
|
||
// Step exists in enum but not in DB → it's a waiting/terminal state
|
||
this.logger.warn(
|
||
`[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`,
|
||
);
|
||
req.workflow.currentStep = nextStepKey;
|
||
req.workflow.nextStep = undefined;
|
||
return;
|
||
}
|
||
|
||
if (!nextStepDoc) {
|
||
// Same case: valid enum, not in DB
|
||
this.logger.warn(
|
||
`[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`,
|
||
);
|
||
req.workflow.currentStep = nextStepKey;
|
||
req.workflow.nextStep = undefined;
|
||
return;
|
||
}
|
||
|
||
// For nextStep (what comes after currentStep), prefer sequential, fallback to nextPossibleSteps
|
||
const sequentialAfterNext = await this.workflowStepDbService.findByStepNumber(
|
||
nextStepDoc.stepNumber + 1,
|
||
);
|
||
|
||
const afterNextKey = (sequentialAfterNext?.stepKey ??
|
||
nextStepDoc.nextPossibleSteps?.[0]) as WorkflowStep | undefined;
|
||
|
||
req.workflow.currentStep = nextStepKey;
|
||
req.workflow.nextStep = afterNextKey;
|
||
|
||
this.logger.debug(
|
||
`[WORKFLOW] Advanced to currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep ?? "undefined"}`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ensures CAR_BODY_ACCIDENT_TYPE step exists in DB (e.g. if initialize was not run after adding CAR_BODY flow).
|
||
*/
|
||
private async ensureCarBodyAccidentTypeStepExists(): Promise<void> {
|
||
const exists = await this.workflowStepDbService.exists(
|
||
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
|
||
);
|
||
if (exists) return;
|
||
|
||
this.logger.log(
|
||
"[WORKFLOW] CAR_BODY_ACCIDENT_TYPE step missing; upserting from default definition",
|
||
);
|
||
await this.workflowStepDbService.upsertByStepKey(
|
||
WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any,
|
||
{
|
||
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
||
type: "CAR_BODY",
|
||
stepNumber: 2,
|
||
isActive: true,
|
||
stepName_fa: "نوع حادثه بدنه",
|
||
stepName_en: "Car Body Accident Type",
|
||
description_fa: "انتخاب اینکه حادثه با خودرو بوده یا با شیء",
|
||
description_en:
|
||
"Select whether accident was with another car or with an object",
|
||
category: "FIRST_PARTY",
|
||
requiredPreviousSteps: [WorkflowStep.CREATED],
|
||
nextPossibleSteps: [WorkflowStep.FIRST_VIDEO],
|
||
allowedRoles: ["user"],
|
||
estimatedDuration: 1,
|
||
fields: [
|
||
{
|
||
id: "507f1f77bcf86cd7994390cb" as any,
|
||
name_fa: "نوع حادثه",
|
||
name_en: "accidentType",
|
||
label_fa: "نوع حادثه",
|
||
label_en: "Accident Type",
|
||
placeholder_fa: "حادثه با خودرو یا شیء؟",
|
||
placeholder_en: "Accident with car or object?",
|
||
value: [
|
||
{ label_fa: "با خودرو", label_en: "With Car", value: "car" },
|
||
{ label_fa: "با شیء", label_en: "With Object", value: "object" },
|
||
],
|
||
dataType: "select",
|
||
required: true,
|
||
order: 1,
|
||
isActive: true,
|
||
validation: { enum: ["car", "object"] },
|
||
},
|
||
],
|
||
metadata: { icon: "car", color: "#10B981" },
|
||
} as any,
|
||
);
|
||
}
|
||
|
||
private async getWorkflowStep(params: {
|
||
stepNumber?: number;
|
||
stepKey?: WorkflowStep;
|
||
}): Promise<WorkflowStepModel> {
|
||
const { stepNumber, stepKey } = params;
|
||
|
||
if (typeof stepNumber !== "number" && !stepKey) {
|
||
throw new BadRequestException("stepNumber or stepKey is required");
|
||
}
|
||
|
||
const stepByNumber =
|
||
typeof stepNumber === "number"
|
||
? await this.workflowStepDbService.findByStepNumber(stepNumber)
|
||
: null;
|
||
|
||
const stepByKey = stepKey
|
||
? await this.workflowStepDbService.findByStepKey(stepKey)
|
||
: null;
|
||
|
||
const step = stepByNumber ?? stepByKey;
|
||
if (!step) {
|
||
throw new InternalServerErrorException(
|
||
`Workflow step not found (${stepNumber ? `stepNumber=${stepNumber}` : ""}${stepNumber && stepKey ? ", " : ""
|
||
}${stepKey ? `stepKey=${stepKey}` : ""})`,
|
||
);
|
||
}
|
||
|
||
// If caller passed both, ensure they resolve to the same step.
|
||
if (stepByNumber && stepByKey && stepByNumber.stepKey !== stepByKey.stepKey) {
|
||
throw new InternalServerErrorException(
|
||
`Workflow step mismatch: stepNumber=${stepNumber} is ${stepByNumber.stepKey}, but stepKey=${stepKey} is stepNumber=${stepByKey.stepNumber}`,
|
||
);
|
||
}
|
||
|
||
return step;
|
||
}
|
||
|
||
constructor(
|
||
private readonly requestManagementDbService: RequestManagementDbService,
|
||
private readonly blameRequestDbService: BlameRequestDbService,
|
||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
||
private readonly blameVideoDbService: BlameVideoDbService,
|
||
private readonly userDbService: UserDbService,
|
||
private readonly sandHubService: SandHubService,
|
||
private readonly sandHubDbService: SandHubDbService,
|
||
private readonly clientService: ClientService,
|
||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
||
private readonly userSign: UserSignDbService,
|
||
private readonly smsManagerService: SmsManagerService,
|
||
private readonly expertDbService: ExpertDbService,
|
||
private readonly autoCloseRequestService: AutoCloseRequestService,
|
||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||
private readonly publicIdService: PublicIdService,
|
||
private readonly workflowStepDbService: WorkflowStepDbService,
|
||
private readonly hashService: HashService,
|
||
private readonly userAuthService: UserAuthService,
|
||
) { }
|
||
|
||
async createRequestV2(user: any, type: BlameRequestType) {
|
||
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,
|
||
) {
|
||
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) {
|
||
throw new ForbiddenException("Only first party can submit this step");
|
||
}
|
||
|
||
// Set userId for first party if not already set
|
||
if (!firstParty.person) firstParty.person = {} as any;
|
||
if (!firstParty.person.userId && user?.sub) {
|
||
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,
|
||
) {
|
||
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?.nextStep) {
|
||
throw new BadRequestException("Request workflow is not initialized");
|
||
}
|
||
if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) {
|
||
throw new BadRequestException(
|
||
`Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${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");
|
||
|
||
if (!firstParty.person) firstParty.person = {} as any;
|
||
if (!firstParty.person.userId && user?.sub) {
|
||
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) {
|
||
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) {
|
||
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`);
|
||
|
||
// 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) {
|
||
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||
? new Types.ObjectId(user.sub)
|
||
: undefined;
|
||
}
|
||
|
||
// Validation: driver/insurer sameness rules
|
||
if (body.driverIsInsurer === false) {
|
||
if (
|
||
body.nationalCodeOfInsurer === body.nationalCodeOfDriver ||
|
||
body.insurerLicense === body.driverLicense
|
||
) {
|
||
throw new BadRequestException(
|
||
"Insurer and Driver should be two different persons in this mode.",
|
||
);
|
||
}
|
||
} else if (body.driverIsInsurer === true) {
|
||
const sameNat = body.nationalCodeOfInsurer === body.nationalCodeOfDriver;
|
||
const sameLic = body.insurerLicense === body.driverLicense;
|
||
const sameBirthday =
|
||
body.driverBirthday == null ||
|
||
String(body.driverBirthday) === String(body.insurerBirthday);
|
||
if (!sameNat || !sameLic || !sameBirthday) {
|
||
throw new BadRequestException(
|
||
"When driverIsInsurer is true, insurer and driver data must be the same.",
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---- External inquiry 1: Tejarat block inquiry ----
|
||
let inquiryRaw: any;
|
||
let inquiryMapped: any;
|
||
try {
|
||
const inquiry = await this.sandHubService.getTejaratBlockInquiry({
|
||
plate: body.plate,
|
||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||
});
|
||
inquiryRaw = inquiry.raw;
|
||
inquiryMapped = inquiry.mapped;
|
||
this.logger.log(
|
||
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
||
);
|
||
this.logger.log(
|
||
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
|
||
);
|
||
} 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)}`,
|
||
);
|
||
}
|
||
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,
|
||
)}`,
|
||
);
|
||
throw new HttpException(
|
||
inquiryMapped.Error.Message || "Inquiry returned error",
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
// NOTE: personal inquiry is not part of Tejarat block inquiry flow.
|
||
|
||
// Find client by company code
|
||
const clientName = inquiryMapped?.CompanyName;
|
||
const companyCode = inquiryMapped?.CompanyCode;
|
||
const client = await this.clientService.findClientWithCompanyCode(
|
||
+companyCode,
|
||
);
|
||
if (!client) {
|
||
throw new HttpException("Client not found", HttpStatus.CONFLICT);
|
||
}
|
||
|
||
// Persist inquiry/body data into new model
|
||
if (!party.person) party.person = {} as any;
|
||
const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id;
|
||
party.person.clientId = resolvedClientId;
|
||
party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer;
|
||
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
|
||
party.person.insurerLicense = body.insurerLicense;
|
||
party.person.driverLicense = body.driverLicense;
|
||
party.person.driverIsInsurer = body.driverIsInsurer;
|
||
party.person.isNewCar = body.isNewCar;
|
||
party.person.userNoCertificate = body.userNoCertificate;
|
||
party.person.insurerBirthday = body.insurerBirthday;
|
||
party.person.driverBirthday =
|
||
body.driverBirthday ?? (body.driverIsInsurer ? String(body.insurerBirthday) : null);
|
||
|
||
if (!party.vehicle) party.vehicle = {} as any;
|
||
party.vehicle.isNew = body.isNewCar;
|
||
party.vehicle.plateId =
|
||
typeof body.plateId === "string"
|
||
? body.plateId
|
||
: this.plateToPlateIdString(body.plate);
|
||
party.vehicle.name = inquiryMapped?.MapTypNam;
|
||
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
||
party.vehicle.inquiry = {
|
||
source: "TEJARAT_BLOCK_INQUIRY",
|
||
raw: inquiryRaw,
|
||
mapped: inquiryMapped,
|
||
};
|
||
|
||
if (!party.insurance) party.insurance = {} as any;
|
||
party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber;
|
||
party.insurance.company = clientName;
|
||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
||
party.insurance.endDate = inquiryMapped?.EndDate;
|
||
|
||
// CAR_BODY: persist mocked car-body insurance in party's insurance (same place as usual inquiry)
|
||
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
|
||
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
|
||
String(req._id),
|
||
body.plate,
|
||
body.nationalCodeOfInsurer,
|
||
);
|
||
(party.insurance as any).carBodyInsurance = carBodyInfo;
|
||
}
|
||
|
||
// Advance workflow
|
||
await this.advanceWorkflowToNext(req, stepKey);
|
||
|
||
// History
|
||
if (!Array.isArray(req.history)) req.history = [];
|
||
req.history.push({
|
||
type: `${stepKey}_SUBMITTED`,
|
||
actor: {
|
||
actorId: Types.ObjectId.isValid(user?.sub)
|
||
? new Types.ObjectId(user.sub)
|
||
: undefined,
|
||
actorName: user?.fullName,
|
||
actorType: "user",
|
||
},
|
||
metadata: {
|
||
role,
|
||
companyCode,
|
||
companyName: clientName,
|
||
},
|
||
} as any);
|
||
|
||
await (req as any).save();
|
||
|
||
return {
|
||
requestId: req._id,
|
||
publicId: req.publicId,
|
||
workflow: req.workflow,
|
||
blameStatus: req.blameStatus,
|
||
status: req.status,
|
||
};
|
||
}
|
||
|
||
async addDetailLocationV2(requestId: string, body: LocationDto, user: any) {
|
||
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");
|
||
|
||
// 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) {
|
||
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,
|
||
) {
|
||
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");
|
||
|
||
// 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) {
|
||
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,
|
||
) {
|
||
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");
|
||
|
||
// 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) {
|
||
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.",
|
||
);
|
||
}
|
||
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;
|
||
}
|
||
|
||
// If second party finished description, we’re ready for expert flow.
|
||
if (stepKey === WorkflowStep.SECOND_DESCRIPTION) {
|
||
req.status = CaseStatus.WAITING_FOR_EXPERT;
|
||
}
|
||
|
||
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: { role },
|
||
} 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",
|
||
);
|
||
|
||
// 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 = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
||
try {
|
||
await this.smsManagerService.verifyLookUp({
|
||
token: url,
|
||
template: "yara724-invite-link",
|
||
receptor: secondPartyPhone,
|
||
});
|
||
this.logger.log(
|
||
`[SMS] Invitation resent to ${secondPartyPhone} for expert-initiated request ${req.publicId}`,
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${err?.message || err}`,
|
||
);
|
||
}
|
||
return {
|
||
requestId: req._id,
|
||
publicId: req.publicId,
|
||
workflow: req.workflow,
|
||
invitationUrl: url,
|
||
};
|
||
}
|
||
throw new ConflictException("Second party already added to this request");
|
||
}
|
||
|
||
// 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 = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
||
try {
|
||
await this.smsManagerService.verifyLookUp({
|
||
token: url,
|
||
template: "yara724-invite-link",
|
||
receptor: phoneNumber,
|
||
});
|
||
this.logger.log(
|
||
`[SMS] Invitation sent to ${phoneNumber} for request ${req.publicId}`,
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[SMS] Failed to send invitation to ${phoneNumber}: ${err?.message || err}`,
|
||
);
|
||
// Don't block the request flow if SMS fails
|
||
}
|
||
|
||
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");
|
||
}
|
||
}
|
||
|
||
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.findClientWithCompanyCode(+companyCode);
|
||
|
||
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 = `${process.env.URL}/${frontendRoutes}?token=${requestId}`;
|
||
try {
|
||
const smsRes = await this.smsManagerService.verifyLookUp({
|
||
token: URL,
|
||
template: "yara724-invite-link",
|
||
receptor: phoneNumber,
|
||
});
|
||
this.logger.log(smsRes);
|
||
} catch (er) {
|
||
this.logger.error("SMS sending failed:", er);
|
||
}
|
||
|
||
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) {
|
||
console.log(
|
||
new Date(p.createdAt).toLocaleTimeString("fa-IR").split(","),
|
||
);
|
||
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 updateDamageExpertStats(
|
||
expertId: string,
|
||
requestId: string,
|
||
type: "checked" | "handled",
|
||
) {
|
||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
||
console.warn("Invalid expertId, requestId, or type");
|
||
return;
|
||
}
|
||
|
||
// Validate that both expertId and requestId are valid ObjectId strings
|
||
if (!Types.ObjectId.isValid(expertId)) {
|
||
console.warn("Invalid ObjectId format for expertId:", expertId);
|
||
return;
|
||
}
|
||
|
||
if (!Types.ObjectId.isValid(requestId)) {
|
||
console.warn("Invalid ObjectId format for requestId:", requestId);
|
||
return;
|
||
}
|
||
|
||
const expert = await this.expertDbService.findOne({
|
||
_id: new Types.ObjectId(expertId),
|
||
});
|
||
|
||
if (!expert) {
|
||
console.warn("Expert not found:", expertId);
|
||
return;
|
||
}
|
||
|
||
const requestIdStr = new Types.ObjectId(requestId).toString();
|
||
const countedRequestIds =
|
||
expert.countedRequests?.map((id) => id.toString()) || [];
|
||
|
||
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
|
||
console.log(
|
||
`Request ${requestIdStr} already checked for expert ${expertId}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const update: any = { $inc: {}, $push: {} };
|
||
|
||
if (type === "checked") {
|
||
update.$inc["requestStats.totalChecked"] = 1;
|
||
update.$push["countedRequests"] = requestIdStr;
|
||
} else if (type === "handled") {
|
||
update.$inc["requestStats.totalHandled"] = 1;
|
||
|
||
if (countedRequestIds.includes(requestIdStr)) {
|
||
update.$inc["requestStats.totalChecked"] = -1;
|
||
}
|
||
|
||
if (!countedRequestIds.includes(requestIdStr)) {
|
||
update.$push["countedRequests"] = requestIdStr;
|
||
} else {
|
||
delete update.$push;
|
||
}
|
||
}
|
||
|
||
const updateResult = await this.expertDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(expertId) },
|
||
update,
|
||
);
|
||
|
||
if (!updateResult) {
|
||
console.warn("Failed to update expert stats for:", expertId);
|
||
} else {
|
||
console.log(`Expert stats updated (${type}) for expert:`, expertId);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`;
|
||
await this.smsManagerService.sendMessage({
|
||
receptor: phoneNumberToNotify,
|
||
message,
|
||
});
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
await this.updateDamageExpertStats(
|
||
actorIdStr,
|
||
requestIdStr,
|
||
"handled",
|
||
);
|
||
}
|
||
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) {
|
||
// TODO FIX SMS SENDING FOR PARTIES
|
||
await this.smsManagerService.sendMessage({
|
||
receptor: phoneNumber,
|
||
message,
|
||
});
|
||
}
|
||
|
||
await this.autoCloseRequestService.scheduleAutoClose({
|
||
requestId: updatedReq._id.toString(),
|
||
runAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||
});
|
||
}
|
||
|
||
async userReply(
|
||
props,
|
||
isAccept: boolean,
|
||
file: Express.Multer.File,
|
||
): Promise<UserReplyDto> {
|
||
const { requestId, user } = props;
|
||
if (!file) throw new BadRequestException("A signature file is required.");
|
||
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
if (!request) throw new NotFoundException("Request not found.");
|
||
|
||
const signDetail = await this.userSign.create({
|
||
fileName: file.filename,
|
||
userId: user.sub,
|
||
path: file.path,
|
||
requestId,
|
||
});
|
||
|
||
const updatePayload = this.buildReplyUpdate(
|
||
request,
|
||
user.sub,
|
||
isAccept,
|
||
signDetail,
|
||
);
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
updatePayload,
|
||
);
|
||
|
||
const updatedReq = await this.requestManagementDbService.findOne(requestId);
|
||
|
||
await this.handlePostReplyStateChanges(updatedReq, request);
|
||
|
||
return new UserReplyDto(requestId);
|
||
}
|
||
|
||
async userResend(
|
||
files: { [key: string]: Express.Multer.File[] },
|
||
user: any,
|
||
requestId: string,
|
||
description: string,
|
||
) {
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
|
||
const partyKey = this.determinePartyAndValidate(request, user.sub);
|
||
|
||
const { voiceId, uploadedDocumentIds } = await this.processResendUploads(
|
||
files,
|
||
requestId,
|
||
);
|
||
|
||
const updatePayload: any = {
|
||
$set: {
|
||
[`expertResendReply.${partyKey}.userReply`]: description,
|
||
},
|
||
};
|
||
|
||
if (voiceId) {
|
||
updatePayload.$set[`expertResendReply.${partyKey}.voice`] = voiceId;
|
||
}
|
||
|
||
for (const docType in uploadedDocumentIds) {
|
||
updatePayload.$set[`expertResendReply.${partyKey}.documents.${docType}`] =
|
||
uploadedDocumentIds[docType];
|
||
}
|
||
|
||
await this.requestManagementDbService.findByIdAndUpdate(
|
||
requestId,
|
||
updatePayload,
|
||
);
|
||
|
||
await this.updateRequestStatusIfNeeded(requestId);
|
||
|
||
return { message: "Update saved successfully." };
|
||
}
|
||
|
||
// Mocked CAR_BODY insurance inquiry – replace with real external API later
|
||
private async mockCarBodyInsuranceInquiry(
|
||
requestId: string,
|
||
plate: any,
|
||
nationalCodeOfInsurer: string,
|
||
): Promise<{
|
||
policyNumber: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
insurerCompany: string;
|
||
coverages: string[];
|
||
}> {
|
||
this.logger.log(
|
||
`[CAR_BODY] Mocking car body insurance inquiry for request ${requestId} (plate=${JSON.stringify(
|
||
plate,
|
||
)}, nationalCodeOfInsurer=${nationalCodeOfInsurer})`,
|
||
);
|
||
|
||
const today = new Date();
|
||
const oneYearLater = new Date(
|
||
today.getFullYear() + 1,
|
||
today.getMonth(),
|
||
today.getDate(),
|
||
);
|
||
|
||
return {
|
||
policyNumber: "CB-MOCK-123456",
|
||
startDate: today.toISOString().slice(0, 10),
|
||
endDate: oneYearLater.toISOString().slice(0, 10),
|
||
insurerCompany: "Mock Car Body Insurance Co.",
|
||
coverages: ["آتشسوزی", "سرقت", "بدنه کامل"],
|
||
};
|
||
}
|
||
|
||
private async processResendUploads(
|
||
files: { [key: string]: Express.Multer.File[] },
|
||
requestId: string,
|
||
) {
|
||
const uploadedDocumentIds: { [key in BlameDocumentType]?: Types.ObjectId } =
|
||
{};
|
||
let voiceId: Types.ObjectId | null = null;
|
||
|
||
for (const fieldName in files) {
|
||
const fileArray = files[fieldName];
|
||
if (fileArray && fileArray.length > 0) {
|
||
const file = fileArray[0];
|
||
|
||
if (fieldName === "voice") {
|
||
voiceId = await this.createVoiceDocument(
|
||
file,
|
||
requestId,
|
||
UploadContext.EXPERT_RESEND,
|
||
);
|
||
} else {
|
||
const documentType = fieldName as BlameDocumentType;
|
||
const docId = await this.createBlameDocument(
|
||
file,
|
||
requestId,
|
||
documentType,
|
||
);
|
||
uploadedDocumentIds[documentType] = docId;
|
||
}
|
||
}
|
||
}
|
||
return { voiceId, uploadedDocumentIds };
|
||
}
|
||
|
||
private async createBlameDocument(
|
||
file: Express.Multer.File,
|
||
requestId: string,
|
||
documentType: BlameDocumentType,
|
||
): Promise<Types.ObjectId> {
|
||
const doc = await this.blameDocumentDbService.create({
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
fileName: file.filename,
|
||
documentType: documentType,
|
||
});
|
||
return (doc as any)._id;
|
||
}
|
||
|
||
private determinePartyAndValidate(
|
||
request: any,
|
||
userId: string,
|
||
): "firstParty" | "secondParty" {
|
||
const firstPartyId = request?.firstPartyDetails?.firstPartyId?.toString();
|
||
const secondPartyId =
|
||
request?.secondPartyDetails?.secondPartyId?.toString();
|
||
|
||
if (userId === firstPartyId) {
|
||
if (request.expertResendReply?.firstParty?.userReply) {
|
||
throw new BadRequestException(
|
||
"You have already submitted a reply for this resend request.",
|
||
);
|
||
}
|
||
return "firstParty";
|
||
}
|
||
|
||
if (userId === secondPartyId) {
|
||
if (request.expertResendReply?.secondParty?.userReply) {
|
||
throw new BadRequestException(
|
||
"You have already submitted a reply for this resend request.",
|
||
);
|
||
}
|
||
return "secondParty";
|
||
}
|
||
|
||
throw new ForbiddenException("User is not a party to this request.");
|
||
}
|
||
|
||
private async createVoiceDocument(
|
||
file: Express.Multer.File,
|
||
requestId: string,
|
||
context: UploadContext,
|
||
): Promise<Types.ObjectId> {
|
||
const voiceDoc = await this.blameVoiceDbService.create({
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
fileName: file.filename,
|
||
context: context,
|
||
});
|
||
return (voiceDoc as any)._id;
|
||
}
|
||
|
||
private async updateRequestStatusIfNeeded(requestId: string): Promise<void> {
|
||
const updatedRequest =
|
||
await this.requestManagementDbService.findOne(requestId);
|
||
const reply = updatedRequest.expertResendReply;
|
||
|
||
if (!reply) {
|
||
return; // No resend initiated, nothing to do.
|
||
}
|
||
|
||
// Helper function to check if a description contains a request for documents.
|
||
const doesDescriptionRequireDocuments = (
|
||
description: string | null | undefined,
|
||
): boolean => {
|
||
if (!description) return false;
|
||
|
||
const validDocTypes = Object.values(BlameDocumentType);
|
||
const descriptionParts = description.split(",").map((p) => p.trim());
|
||
|
||
// Return true if any part of the description matches a known document type.
|
||
return descriptionParts.some((part) =>
|
||
validDocTypes.some(
|
||
(docType) =>
|
||
docType.replace(/_/g, "").toUpperCase() === part.toUpperCase(),
|
||
),
|
||
);
|
||
};
|
||
|
||
// 1. Determine which parties were actually asked to resubmit DOCUMENTS.
|
||
const requiredPartiesToReply: ("firstParty" | "secondParty")[] = [];
|
||
|
||
if (
|
||
reply.firstParty &&
|
||
doesDescriptionRequireDocuments(reply.firstParty.firstPartyDescription)
|
||
) {
|
||
requiredPartiesToReply.push("firstParty");
|
||
}
|
||
if (
|
||
reply.secondParty &&
|
||
doesDescriptionRequireDocuments(reply.secondParty.secondPartyDescription)
|
||
) {
|
||
requiredPartiesToReply.push("secondParty");
|
||
}
|
||
|
||
if (requiredPartiesToReply.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const allRequiredRepliesAreIn = requiredPartiesToReply.every(
|
||
(party) => !!reply[party]?.userReply,
|
||
);
|
||
|
||
if (
|
||
allRequiredRepliesAreIn &&
|
||
updatedRequest.blameStatus !== ReqBlameStatus.CheckAgain
|
||
) {
|
||
await this.requestManagementDbService.findByIdAndUpdate(requestId, {
|
||
blameStatus: ReqBlameStatus.CheckAgain,
|
||
});
|
||
this.logger.log(
|
||
`Request ${requestId} status updated to CheckAgain as all required parties have resubmitted.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async addedClientKey(data: { clientName: any; companyCode: number }) {
|
||
if (process.env.ADDED_AUTO_CLIENT_KEY) {
|
||
let clientExist = await this.clientService.findOne({
|
||
clientName: data.clientName,
|
||
});
|
||
if (!clientExist) {
|
||
try {
|
||
await this.clientService.addClient({
|
||
clientCode: data.companyCode,
|
||
clientName: {
|
||
persian: data.clientName,
|
||
english: null,
|
||
},
|
||
property: {
|
||
smsApiKey: null,
|
||
},
|
||
useExpertMode: "genuine",
|
||
});
|
||
} catch (err) {
|
||
console.log(err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verify that expert can access this file (only if they initiated it)
|
||
*/
|
||
private async verifyExpertAccess(
|
||
request: RequestManagementModel,
|
||
user: any,
|
||
): Promise<void> {
|
||
// If user is not an expert type, no verification needed (normal flow)
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (!expertRoles.includes(user.role)) {
|
||
return;
|
||
}
|
||
|
||
// If file is not expert-initiated, experts cannot access it
|
||
if (!request.expertInitiated || !request.initiatedBy) {
|
||
throw new ForbiddenException(
|
||
"Experts can only access files they have initiated",
|
||
);
|
||
}
|
||
|
||
// Verify the expert is the one who initiated this file
|
||
if (String(request.initiatedBy) !== user.sub) {
|
||
throw new ForbiddenException(
|
||
"You can only access files that you have initiated",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verify the current user (field expert) can access this BlameRequest (v2).
|
||
*/
|
||
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
|
||
if (!req?.expertInitiated || !req?.initiatedByFieldExpertId) {
|
||
throw new ForbiddenException(
|
||
"This file is not expert-initiated or has no initiating expert",
|
||
);
|
||
}
|
||
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
|
||
throw new ForbiddenException(
|
||
"You can only access files that you have initiated",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
|
||
if (dto.creationMethod === CreationMethod.LINK) {
|
||
if (!dto.firstPartyPhoneNumber) {
|
||
throw new BadRequestException(
|
||
"First party phone number is required for LINK method",
|
||
);
|
||
}
|
||
if (
|
||
type === BlameRequestType.THIRD_PARTY &&
|
||
!dto.secondPartyPhoneNumber
|
||
) {
|
||
throw new BadRequestException(
|
||
"Second party phone number is required for LINK method with THIRD_PARTY type",
|
||
);
|
||
}
|
||
if (
|
||
type === BlameRequestType.THIRD_PARTY &&
|
||
dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber
|
||
) {
|
||
throw new BadRequestException(
|
||
"First and second party phone numbers must be different",
|
||
);
|
||
}
|
||
}
|
||
|
||
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
|
||
if (!firstStep?.stepKey) {
|
||
throw new InternalServerErrorException(
|
||
"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[] = [];
|
||
|
||
if (dto.creationMethod === CreationMethod.LINK) {
|
||
const firstUserId = await this.getOrCreateUserByPhoneNumber(
|
||
dto.firstPartyPhoneNumber,
|
||
);
|
||
parties.push({
|
||
role: PartyRole.FIRST,
|
||
person: {
|
||
userId: firstUserId,
|
||
phoneNumber: dto.firstPartyPhoneNumber,
|
||
},
|
||
});
|
||
if (
|
||
type === BlameRequestType.THIRD_PARTY &&
|
||
dto.secondPartyPhoneNumber
|
||
) {
|
||
const secondUserId = await this.getOrCreateUserByPhoneNumber(
|
||
dto.secondPartyPhoneNumber,
|
||
);
|
||
parties.push({
|
||
role: PartyRole.SECOND,
|
||
person: {
|
||
userId: secondUserId,
|
||
phoneNumber: dto.secondPartyPhoneNumber,
|
||
},
|
||
});
|
||
}
|
||
} else {
|
||
parties.push({
|
||
role: PartyRole.FIRST,
|
||
person: {},
|
||
});
|
||
}
|
||
|
||
const created = await this.blameRequestDbService.create({
|
||
publicId,
|
||
requestNo: publicId,
|
||
type,
|
||
parties,
|
||
workflow: {
|
||
currentStep: firstStep.stepKey as WorkflowStep,
|
||
nextStep,
|
||
completedSteps: [firstStep.stepKey as WorkflowStep],
|
||
locked: false,
|
||
},
|
||
history: [],
|
||
expertInitiated: true,
|
||
initiatedByFieldExpertId: expertId,
|
||
creationMethod: dto.creationMethod,
|
||
filledBy:
|
||
dto.creationMethod === CreationMethod.IN_PERSON
|
||
? FilledBy.EXPERT
|
||
: FilledBy.CUSTOMER,
|
||
});
|
||
|
||
const requestId = String((created as any)._id);
|
||
if (!Array.isArray((created as any).history)) (created as any).history = [];
|
||
(created as any).history.push({
|
||
type: "FILE_CREATED_BY_FIELD_EXPERT",
|
||
actor: {
|
||
actorId: expertId,
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: { creationMethod: dto.creationMethod, type: dto.type },
|
||
});
|
||
await (created as any).save();
|
||
|
||
const result: {
|
||
requestId: string;
|
||
publicId: string;
|
||
linkUrl?: string;
|
||
} = {
|
||
requestId,
|
||
publicId: (created as any).publicId,
|
||
};
|
||
if (dto.creationMethod === CreationMethod.LINK) {
|
||
const baseUrl =
|
||
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
|
||
result.linkUrl = baseUrl
|
||
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
|
||
: undefined;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* V2: Send blame link to first party (and second party for THIRD_PARTY) for expert-initiated LINK method.
|
||
* SMS delivery is mocked for now; replace with real SMS provider later. First party opens the link and fills the form via normal v2 flow.
|
||
*/
|
||
async sendLinkV2(
|
||
expert: any,
|
||
requestId: string,
|
||
): Promise<{
|
||
sent: boolean;
|
||
linkUrl: string;
|
||
sentTo: { role: string; phoneNumber: string }[];
|
||
}> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.",
|
||
);
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
const baseUrl =
|
||
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
|
||
const linkUrl = baseUrl
|
||
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
|
||
: "";
|
||
|
||
const sentTo: { role: string; phoneNumber: string }[] = [];
|
||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||
if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) {
|
||
const phone = req.parties[firstIdx].person.phoneNumber;
|
||
this.logger.log(
|
||
`[MOCK SMS] Would send link to first party ${phone}: ${linkUrl}`,
|
||
);
|
||
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone });
|
||
}
|
||
if (req.type === BlameRequestType.THIRD_PARTY) {
|
||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||
if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) {
|
||
const phone = req.parties[secondIdx].person.phoneNumber;
|
||
this.logger.log(
|
||
`[MOCK SMS] Would send link to second party ${phone}: ${linkUrl}`,
|
||
);
|
||
sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone });
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray((req as any).history)) (req as any).history = [];
|
||
(req as any).history.push({
|
||
type: "LINK_SENT",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: { linkUrl, sentTo },
|
||
});
|
||
await (req as any).save();
|
||
|
||
return { sent: true, linkUrl, sentTo };
|
||
}
|
||
|
||
/**
|
||
* V2: Send OTP via SMS to one or two parties for expert-initiated IN_PERSON blame.
|
||
* Uses the same flow as /user/send-otp (same template, expiry). After this, parties receive SMS; expert collects OTPs and calls verify-party-otps.
|
||
*/
|
||
async sendPartyOtpsV2(
|
||
expert: any,
|
||
requestId: string,
|
||
dto: { firstPartyPhoneNumber: string; secondPartyPhoneNumber?: string },
|
||
): Promise<{ sent: boolean; message: string }> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
if (req.type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber) {
|
||
throw new BadRequestException(
|
||
"Second party phone number is required for THIRD_PARTY. Provide secondPartyPhoneNumber to send OTP to both parties.",
|
||
);
|
||
}
|
||
const sent: string[] = [];
|
||
try {
|
||
await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber);
|
||
sent.push(dto.firstPartyPhoneNumber);
|
||
} catch (e: any) {
|
||
if (e?.message?.includes("Wait for expiry")) {
|
||
throw new BadRequestException(
|
||
`First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||
);
|
||
}
|
||
throw e;
|
||
}
|
||
if (dto.secondPartyPhoneNumber) {
|
||
try {
|
||
await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber);
|
||
sent.push(dto.secondPartyPhoneNumber);
|
||
} catch (e: any) {
|
||
if (e?.message?.includes("Wait for expiry")) {
|
||
throw new BadRequestException(
|
||
`Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||
);
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
return {
|
||
sent: true,
|
||
message:
|
||
sent.length === 2
|
||
? `OTP sent to both parties (${sent.join(", ")}). Have them tell you the code, then call verify-party-otps.`
|
||
: `OTP sent to ${sent[0]}. Have the party tell you the code, then call verify-party-otps.`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Verify one or two party OTPs for expert-initiated IN_PERSON blame.
|
||
* Call this after send-party-otps (or after parties requested OTP via /user/send-otp). On success, parties are linked to their user ids so the expert can proceed with complete-blame-data.
|
||
*/
|
||
async verifyPartyOtpsV2(
|
||
expert: any,
|
||
requestId: string,
|
||
dto: {
|
||
firstPartyPhoneNumber: string;
|
||
firstPartyOtp: string;
|
||
secondPartyPhoneNumber?: string;
|
||
secondPartyOtp?: string;
|
||
},
|
||
): Promise<{ verified: boolean; message: string }> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
const now = Date.now();
|
||
const verifyOne = async (phone: string, otp: string): Promise<Types.ObjectId> => {
|
||
const user = await this.userDbService.findOne({
|
||
$or: [{ username: phone }, { mobile: phone }],
|
||
});
|
||
if (!user) throw new BadRequestException(`User not found for phone: ${phone}`);
|
||
const u = user as any;
|
||
if (u.otp == null) throw new BadRequestException(`No OTP requested for ${phone}. User must call /user/send-otp first.`);
|
||
if (u.otpExpire < now) throw new BadRequestException(`OTP expired for ${phone}. User must request a new OTP.`);
|
||
const valid = await this.hashService.compare(otp, u.otp);
|
||
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
|
||
return u._id;
|
||
};
|
||
|
||
const firstUserId = await verifyOne(dto.firstPartyPhoneNumber, dto.firstPartyOtp);
|
||
if (!Array.isArray(req.parties)) req.parties = [];
|
||
const firstIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.FIRST);
|
||
if (firstIdx === -1) throw new BadRequestException("First party not found on request");
|
||
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||
req.parties[firstIdx].person.userId = firstUserId;
|
||
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber;
|
||
|
||
if (req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber && dto.secondPartyOtp) {
|
||
const secondUserId = await verifyOne(dto.secondPartyPhoneNumber, dto.secondPartyOtp);
|
||
let secondIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.SECOND);
|
||
if (secondIdx === -1) {
|
||
req.parties.push({
|
||
role: PartyRole.SECOND,
|
||
person: {
|
||
userId: secondUserId,
|
||
phoneNumber: dto.secondPartyPhoneNumber,
|
||
},
|
||
} as any);
|
||
} else {
|
||
if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any;
|
||
req.parties[secondIdx].person.userId = secondUserId;
|
||
req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber;
|
||
}
|
||
} else if (req.type === BlameRequestType.THIRD_PARTY && (dto.secondPartyPhoneNumber || dto.secondPartyOtp)) {
|
||
throw new BadRequestException("For THIRD_PARTY both secondPartyPhoneNumber and secondPartyOtp are required.");
|
||
}
|
||
|
||
if (!Array.isArray(req.history)) req.history = [];
|
||
req.history.push({
|
||
type: "PARTY_OTPS_VERIFIED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: {
|
||
firstPartyVerified: true,
|
||
secondPartyVerified: req.type === BlameRequestType.THIRD_PARTY && !!dto.secondPartyPhoneNumber,
|
||
},
|
||
} as any);
|
||
await (req as any).save();
|
||
|
||
return {
|
||
verified: true,
|
||
message: req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber
|
||
? "Both parties verified. You can proceed to fill the blame form."
|
||
: "First party verified. You can proceed to fill the blame form.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* List all expert-initiated blame files for the current field expert (their panel).
|
||
* Only files where initiatedBy === current user are returned.
|
||
*/
|
||
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({
|
||
expertInitiated: true,
|
||
initiatedByFieldExpertId: 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): Promise<any> {
|
||
console.log(user)
|
||
try {
|
||
const requests = await this.blameRequestDbService.find({
|
||
parties: {
|
||
$elemMatch: {
|
||
'person.userId': new Types.ObjectId(user.sub)
|
||
}
|
||
}
|
||
}, { select: "requestNo type status blameStatus createdAt updatedAt" });
|
||
return requests;
|
||
} catch (err) {
|
||
this.logger.error("Error: ", err);
|
||
throw new InternalServerErrorException(
|
||
"Something Went Wrong",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Get one blame request by id. Access allowed if current user is a party (by userId or phone)
|
||
* or the initiating field expert. For expert-initiated LINK, party access by phone is sufficient.
|
||
*/
|
||
async getBlameRequestV2(requestId: string, user: any): Promise<any> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
const isFieldExpertOwner =
|
||
req.expertInitiated &&
|
||
req.initiatedByFieldExpertId &&
|
||
String(req.initiatedByFieldExpertId) === String(user?.sub);
|
||
const isParty = Array.isArray(req.parties) && req.parties.some((p: any) => {
|
||
const pid = p?.person?.userId ? String(p.person.userId) : null;
|
||
const phone = p?.person?.phoneNumber;
|
||
return (pid && pid === String(user?.sub)) || (phone && phone === user?.username);
|
||
});
|
||
if (!isFieldExpertOwner && !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 && user?.sub && Types.ObjectId.isValid(user.sub)) {
|
||
let updated = false;
|
||
for (const p of req.parties || []) {
|
||
if (
|
||
p?.person?.phoneNumber === user?.username &&
|
||
!p.person?.userId
|
||
) {
|
||
p.person = p.person || {};
|
||
(p.person as any).userId = new Types.ObjectId(user.sub);
|
||
updated = true;
|
||
}
|
||
}
|
||
if (updated) {
|
||
await (req as any).save();
|
||
}
|
||
}
|
||
return req;
|
||
}
|
||
|
||
/**
|
||
* 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.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON BlameRequest files.",
|
||
);
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
if (req.type === BlameRequestType.THIRD_PARTY) {
|
||
return this.expertCompleteThirdPartyFormV2(expert, requestId, formData);
|
||
}
|
||
if (req.type === BlameRequestType.CAR_BODY) {
|
||
return this.expertCompleteCarBodyFormV2(expert, requestId, formData);
|
||
}
|
||
throw new BadRequestException(
|
||
"Unknown file type. Must be THIRD_PARTY or CAR_BODY.",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* V2: Expert completes IN_PERSON CAR_BODY BlameRequest in one go.
|
||
*/
|
||
async expertCompleteCarBodyFormV2(
|
||
expert: any,
|
||
requestId: string,
|
||
formData: any,
|
||
): Promise<any> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException("Only expert-initiated IN_PERSON CAR_BODY files.");
|
||
}
|
||
if (req.type !== BlameRequestType.CAR_BODY) {
|
||
throw new BadRequestException("File type is not CAR_BODY.");
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
if (!formData.carBodyForm) {
|
||
throw new BadRequestException("carBodyForm is required.");
|
||
}
|
||
|
||
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;
|
||
} catch (e) {
|
||
this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e);
|
||
throw new InternalServerErrorException("Failed to process plate information.");
|
||
}
|
||
|
||
const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
|
||
const companyCode = sandHubReport?.CompanyCode;
|
||
const client = companyCode
|
||
? await this.clientService.findClientWithCompanyCode(+companyCode)
|
||
: await this.clientService.findOne({ clientName });
|
||
if (!client) {
|
||
throw new NotFoundException(`Client not found for company: ${clientName}`);
|
||
}
|
||
|
||
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
|
||
requestId,
|
||
firstPartyPlate.plate,
|
||
firstPartyPlate.nationalCodeOfInsurer,
|
||
);
|
||
|
||
const firstParty: any = {
|
||
role: PartyRole.FIRST,
|
||
person: {
|
||
userId: firstPartyUserId,
|
||
phoneNumber: formData.firstPartyPhoneNumber,
|
||
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
|
||
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
||
insurerLicense: firstPartyPlate.insurerLicense,
|
||
driverLicense: firstPartyPlate.driverLicense,
|
||
driverIsInsurer: firstPartyPlate.driverIsInsurer,
|
||
isNewCar: firstPartyPlate.isNewCar,
|
||
userNoCertificate: firstPartyPlate.userNoCertificate,
|
||
insurerBirthday: firstPartyPlate.insurerBirthday,
|
||
driverBirthday: firstPartyPlate.driverBirthday,
|
||
},
|
||
vehicle: {
|
||
plateId: this.plateToPlateIdString(firstPartyPlate.plate) || (firstPartyPlate as any).plateId,
|
||
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||
model: sandHubReport?.MapTypNam,
|
||
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||
isNew: firstPartyPlate.isNewCar,
|
||
inquiry: sandHubReport,
|
||
},
|
||
insurance: {
|
||
policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber,
|
||
company: clientName,
|
||
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
|
||
endDate: sandHubReport?.EndDate,
|
||
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
|
||
carBodyInsurance: {
|
||
policyNumber: carBodyInfo.policyNumber,
|
||
startDate: carBodyInfo.startDate,
|
||
endDate: carBodyInfo.endDate,
|
||
insurerCompany: carBodyInfo.insurerCompany,
|
||
coverages: carBodyInfo.coverages,
|
||
},
|
||
},
|
||
statement: {
|
||
description: formData.firstPartyDescription?.desc,
|
||
accidentDate: formData.firstPartyDescription?.accidentDate,
|
||
accidentTime: formData.firstPartyDescription?.accidentTime,
|
||
weatherCondition: formData.firstPartyDescription?.weatherCondition,
|
||
roadCondition: formData.firstPartyDescription?.roadCondition,
|
||
lightCondition: formData.firstPartyDescription?.lightCondition,
|
||
},
|
||
location: formData.firstPartyLocation,
|
||
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.WAITING_FOR_SIGNATURES as any,
|
||
nextStep: undefined,
|
||
completedSteps: [
|
||
WorkflowStep.CREATED,
|
||
WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
||
WorkflowStep.FIRST_VIDEO,
|
||
WorkflowStep.FIRST_INITIAL_FORM,
|
||
WorkflowStep.FIRST_LOCATION,
|
||
WorkflowStep.FIRST_VOICE,
|
||
WorkflowStep.FIRST_DESCRIPTION,
|
||
].filter((s) => s),
|
||
locked: false,
|
||
};
|
||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||
req.blameStatus = BlameStatus.AGREED;
|
||
(req as any).filledBy = FilledBy.EXPERT;
|
||
|
||
if (!Array.isArray(req.history)) req.history = [];
|
||
req.history.push({
|
||
type: "EXPERT_COMPLETED_CAR_BODY_FORM_V2",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: {},
|
||
} as any);
|
||
await (req as any).save();
|
||
|
||
return {
|
||
requestId: req._id,
|
||
publicId: req.publicId,
|
||
workflow: req.workflow,
|
||
status: req.status,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Expert completes IN_PERSON THIRD_PARTY BlameRequest in one go.
|
||
*/
|
||
async expertCompleteThirdPartyFormV2(
|
||
expert: any,
|
||
requestId: string,
|
||
formData: any,
|
||
): Promise<any> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException("Only expert-initiated IN_PERSON THIRD_PARTY files.");
|
||
}
|
||
if (req.type !== BlameRequestType.THIRD_PARTY) {
|
||
throw new BadRequestException("File type is not THIRD_PARTY.");
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
|
||
throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required.");
|
||
}
|
||
|
||
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,
|
||
locationDto: any,
|
||
desc: string,
|
||
role: PartyRole,
|
||
) => {
|
||
const sandHubResponse = await this.sandHubService.getSandHubResponse({
|
||
plate: plateDto.plate,
|
||
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
||
});
|
||
const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any;
|
||
const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName;
|
||
const companyCode = sandHubReport?.CompanyCode;
|
||
const client = companyCode
|
||
? await this.clientService.findClientWithCompanyCode(+companyCode)
|
||
: await this.clientService.findOne({ clientName });
|
||
if (!client) {
|
||
throw new NotFoundException(`Client not found for company: ${clientName}`);
|
||
}
|
||
return {
|
||
role,
|
||
person: {
|
||
userId,
|
||
phoneNumber,
|
||
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
||
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
||
insurerLicense: plateDto.insurerLicense,
|
||
driverLicense: plateDto.driverLicense,
|
||
driverIsInsurer: plateDto.driverIsInsurer,
|
||
isNewCar: plateDto.isNewCar,
|
||
userNoCertificate: plateDto.userNoCertificate,
|
||
insurerBirthday: plateDto.insurerBirthday,
|
||
driverBirthday: plateDto.driverBirthday,
|
||
},
|
||
vehicle: {
|
||
plateId: this.plateToPlateIdString(plateDto.plate) || (plateDto as any).plateId,
|
||
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||
model: sandHubReport?.MapTypNam,
|
||
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||
isNew: plateDto.isNewCar,
|
||
inquiry: sandHubReport,
|
||
},
|
||
insurance: {
|
||
policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber,
|
||
company: clientName,
|
||
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
|
||
endDate: sandHubReport?.EndDate,
|
||
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
|
||
},
|
||
statement: { description: desc },
|
||
location: locationDto,
|
||
evidence: {},
|
||
};
|
||
};
|
||
|
||
const firstParty = await buildPartyFromForm(
|
||
formData.firstPartyPhoneNumber,
|
||
firstPartyUserId,
|
||
formData.firstPartyInitialForm,
|
||
formData.firstPartyPlate,
|
||
formData.firstPartyLocation,
|
||
formData.firstPartyDescription?.desc || "",
|
||
PartyRole.FIRST,
|
||
);
|
||
const secondParty = await buildPartyFromForm(
|
||
formData.secondParty.phoneNumber,
|
||
secondPartyUserId,
|
||
formData.secondParty.initialForm,
|
||
formData.secondParty.plate,
|
||
formData.secondParty.location,
|
||
formData.secondParty.description?.desc || "",
|
||
PartyRole.SECOND,
|
||
);
|
||
|
||
req.parties = [firstParty, secondParty];
|
||
const guiltyPartyId =
|
||
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber
|
||
? String(firstPartyUserId)
|
||
: String(secondPartyUserId);
|
||
if (!req.expert) req.expert = {} as any;
|
||
req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any;
|
||
req.workflow = {
|
||
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||
nextStep: undefined,
|
||
completedSteps: [
|
||
WorkflowStep.CREATED,
|
||
WorkflowStep.FIRST_BLAME_CONFESSION,
|
||
WorkflowStep.FIRST_VIDEO,
|
||
WorkflowStep.FIRST_INITIAL_FORM,
|
||
WorkflowStep.FIRST_LOCATION,
|
||
WorkflowStep.FIRST_VOICE,
|
||
WorkflowStep.FIRST_DESCRIPTION,
|
||
WorkflowStep.SECOND_INITIAL_FORM,
|
||
WorkflowStep.SECOND_LOCATION,
|
||
WorkflowStep.SECOND_VOICE,
|
||
WorkflowStep.SECOND_DESCRIPTION,
|
||
].filter((s) => s),
|
||
locked: false,
|
||
};
|
||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||
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 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) {
|
||
throw new BadRequestException("This endpoint is only for expert-initiated files.");
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
|
||
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
|
||
const firstParty = req.parties[firstPartyIndex];
|
||
|
||
if (firstParty?.evidence?.videoId) {
|
||
throw new ConflictException("Video already uploaded for this file");
|
||
}
|
||
|
||
const videoDocument = await this.blameVideoDbService.create({
|
||
fileName: file.filename,
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
} as any);
|
||
|
||
if (!firstParty.evidence) firstParty.evidence = {} as any;
|
||
firstParty.evidence.videoId = String((videoDocument as any)._id);
|
||
await (req as any).save();
|
||
|
||
if (!Array.isArray(req.history)) req.history = [];
|
||
req.history.push({
|
||
type: "EXPERT_UPLOADED_VIDEO_V2",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: { videoId: firstParty.evidence.videoId },
|
||
} as any);
|
||
await (req as any).save();
|
||
|
||
return { requestId: req._id, videoId: firstParty.evidence.videoId };
|
||
}
|
||
|
||
/**
|
||
* V2: Expert uploads voice for expert-initiated BlameRequest (first party).
|
||
*/
|
||
async expertUploadVoiceForBlameV2(
|
||
expert: any,
|
||
requestId: string,
|
||
voiceFile: Express.Multer.File,
|
||
): Promise<any> {
|
||
if (!voiceFile) throw new BadRequestException("Voice file is required");
|
||
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated) {
|
||
throw new BadRequestException("This endpoint is only for expert-initiated files.");
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
|
||
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
|
||
const firstParty = req.parties[firstPartyIndex];
|
||
|
||
const voiceDocument = await this.blameVoiceDbService.create({
|
||
fileName: voiceFile.filename,
|
||
path: voiceFile.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
} as any);
|
||
|
||
if (!firstParty.evidence) firstParty.evidence = {} as any;
|
||
if (!Array.isArray(firstParty.evidence.voices)) firstParty.evidence.voices = [];
|
||
firstParty.evidence.voices.push(String((voiceDocument as any)._id));
|
||
await (req as any).save();
|
||
|
||
if (!Array.isArray(req.history)) req.history = [];
|
||
req.history.push({
|
||
type: "EXPERT_UPLOADED_VOICE_V2",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
metadata: {},
|
||
} as any);
|
||
await (req as any).save();
|
||
|
||
return { requestId: req._id };
|
||
}
|
||
|
||
/**
|
||
* V2: Expert uploads a party's signature for expert-initiated IN_PERSON blame.
|
||
* CAR_BODY: upload FIRST only. THIRD_PARTY: upload FIRST then SECOND.
|
||
* When all required parties have signed, status becomes COMPLETED.
|
||
*/
|
||
async expertUploadPartySignatureV2(
|
||
expert: any,
|
||
requestId: string,
|
||
partyRole: PartyRole,
|
||
isAccept: boolean,
|
||
signFile: Express.Multer.File,
|
||
): Promise<UserSignatureResponseDto> {
|
||
if (!signFile) {
|
||
throw new BadRequestException("A signature file is required");
|
||
}
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
|
||
throw new BadRequestException(
|
||
"Request is not waiting for signatures. Current status: " + req.status,
|
||
);
|
||
}
|
||
const partyIndex = this.getPartyIndex(req, partyRole);
|
||
if (partyIndex === -1) {
|
||
throw new BadRequestException(`Party ${partyRole} not found on this request`);
|
||
}
|
||
if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.SECOND) {
|
||
throw new BadRequestException("CAR_BODY has only first party; use partyRole FIRST.");
|
||
}
|
||
const party = req.parties[partyIndex];
|
||
if (party.confirmation) {
|
||
throw new BadRequestException(
|
||
`Party ${partyRole} has already signed.`,
|
||
);
|
||
}
|
||
const partyUserId = party.person?.userId ? String(party.person.userId) : undefined;
|
||
const signDoc = await this.userSign.create({
|
||
fileName: signFile.filename,
|
||
userId: partyUserId || expert.sub,
|
||
path: signFile.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
});
|
||
const signatureData = {
|
||
fileId: String((signDoc as any)._id),
|
||
fileName: signFile.filename,
|
||
fileUrl: signFile.path,
|
||
};
|
||
const updatePayload: any = {
|
||
$set: {
|
||
[`parties.${partyIndex}.confirmation`]: {
|
||
partyRole: party.role,
|
||
accepted: isAccept,
|
||
signature: signatureData,
|
||
},
|
||
},
|
||
};
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||
const requiredCount = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
|
||
const signedParties = (updatedRequest.parties || []).filter(
|
||
(p) => p.confirmation != null && p.confirmation !== undefined,
|
||
);
|
||
let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES;
|
||
let message = "Signature recorded successfully.";
|
||
if (signedParties.length >= requiredCount) {
|
||
const allAccepted = updatedRequest.parties
|
||
.slice(0, requiredCount)
|
||
.every((p) => p.confirmation?.accepted === true);
|
||
if (allAccepted) {
|
||
finalStatus = CaseStatus.COMPLETED;
|
||
message = "All parties have signed. Blame case completed.";
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||
$set: {
|
||
status: CaseStatus.COMPLETED,
|
||
"workflow.currentStep": WorkflowStep.COMPLETED as any,
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||
},
|
||
});
|
||
} else {
|
||
finalStatus = CaseStatus.CANCELLED;
|
||
message = "One or both parties rejected. Case requires in-person resolution.";
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||
$set: {
|
||
status: CaseStatus.CANCELLED,
|
||
"workflow.currentStep": WorkflowStep.COMPLETED as any,
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
requestId: String(req._id),
|
||
accepted: isAccept,
|
||
status: finalStatus,
|
||
message,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Expert adds accident fields to expert-initiated BlameRequest.
|
||
*/
|
||
async expertAddAccidentFieldsForBlameV2(
|
||
expert: any,
|
||
requestId: string,
|
||
fields: any,
|
||
): Promise<any> {
|
||
const req = await this.blameRequestDbService.findById(requestId);
|
||
if (!req) throw new NotFoundException("Request not found");
|
||
if (!req.expertInitiated) {
|
||
throw new BadRequestException("This endpoint is only for expert-initiated files.");
|
||
}
|
||
this.verifyExpertAccessForBlameV2(req, expert);
|
||
|
||
if (!req.expert) req.expert = {} as any;
|
||
if (!req.expert.decision) req.expert.decision = {} as any;
|
||
(req.expert as any).decision = {
|
||
...(req.expert as any).decision,
|
||
fields: {
|
||
accidentWay: fields.accidentWay,
|
||
accidentReason: fields.accidentReason,
|
||
accidentType: fields.accidentType,
|
||
},
|
||
};
|
||
await (req as any).save();
|
||
|
||
return { requestId: req._id };
|
||
}
|
||
|
||
/**
|
||
* 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.findClientWithCompanyCode(+companyCode)
|
||
: 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.findClientWithCompanyCode(+companyCode)
|
||
: 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.findClientWithCompanyCode(+companyCode)
|
||
: await this.clientService.findOne({ clientName: clientName });
|
||
|
||
if (!client) {
|
||
throw new NotFoundException(
|
||
`Client not found for company: ${clientName}`,
|
||
);
|
||
}
|
||
|
||
// Add first party plate and related data to the existing firstPartyDetails object
|
||
firstPartyDetails.nationalCodeOfInsurer = firstPartyPlate.nationalCodeOfInsurer;
|
||
firstPartyDetails.firstPartyPlate = firstPartyPlate.plate;
|
||
firstPartyDetails.firstPartyCarDetail = {
|
||
carName: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||
insurerBirthday: firstPartyPlate.insurerBirthday,
|
||
driverBirthday: firstPartyPlate.driverBirthday,
|
||
carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||
isNewCar: firstPartyPlate.isNewCar,
|
||
};
|
||
firstPartyDetails.firstPartyClient = {
|
||
clientId: (client as any)["_doc"]?._id || (client as any)._id,
|
||
clientName: clientName,
|
||
};
|
||
firstPartyDetails.firstPartyCertificate = firstPartyPlate.userNoCertificate;
|
||
firstPartyDetails.firstPartyInsuranceDetail = {
|
||
insuranceId: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber,
|
||
insuranceCompany: clientName,
|
||
financialCommitmentCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
|
||
startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate,
|
||
endDate: sandHubReport?.EndDate,
|
||
};
|
||
|
||
// CAR_BODY specific insurance info
|
||
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
|
||
request._id.toString(),
|
||
firstPartyPlate.plate,
|
||
firstPartyPlate.nationalCodeOfInsurer,
|
||
);
|
||
|
||
firstPartyDetails.firstPartyCarBodyInsuranceDetail = {
|
||
policyNumber: carBodyInfo.policyNumber,
|
||
startDate: carBodyInfo.startDate,
|
||
endDate: carBodyInfo.endDate,
|
||
insurerCompany: carBodyInfo.insurerCompany,
|
||
coverages: carBodyInfo.coverages,
|
||
};
|
||
|
||
// Update the firstPartyDetails in the payload with all plate data
|
||
updatePayload.$set.firstPartyDetails = firstPartyDetails;
|
||
|
||
stepsToAdd.push(StepsEnum.F_addPlate);
|
||
} catch (plateError) {
|
||
this.logger.error("Error processing first party plate:", plateError);
|
||
throw new InternalServerErrorException(
|
||
"Failed to process first party plate information",
|
||
);
|
||
}
|
||
|
||
// For CAR_BODY: Create expertSubmitReply
|
||
// First party is guilty by default if accident was with another car
|
||
const guiltyUserId = firstPartyUserId;
|
||
|
||
// Accident fields can be added separately via add-accident-fields endpoint
|
||
updatePayload.$set.expertSubmitReply = {
|
||
description: "Expert completed all information on-site. Awaiting signature.",
|
||
guiltyUserId: guiltyUserId,
|
||
submitTime: new Date(),
|
||
fields: {
|
||
accidentWay: {
|
||
id: "",
|
||
label: "To be determined",
|
||
},
|
||
accidentReason: {
|
||
id: "",
|
||
label: "To be determined",
|
||
fanavaran: 0,
|
||
},
|
||
accidentType: {
|
||
id: "",
|
||
label: "To be determined",
|
||
},
|
||
},
|
||
firstPartyComment: null,
|
||
secondPartyComment: null,
|
||
};
|
||
|
||
updatePayload.$set.currentStep = StepsEnum.F_addDescription;
|
||
updatePayload.$set.nextStep = StepsEnum.AddSignatures;
|
||
updatePayload.$set.blameStatus = ReqBlameStatus.WaitingForSignatures;
|
||
|
||
// Add all steps at once using $each to push multiple values
|
||
if (stepsToAdd.length > 0) {
|
||
updatePayload.$push = {
|
||
steps: { $each: stepsToAdd },
|
||
};
|
||
}
|
||
|
||
// Execute the update
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
updatePayload,
|
||
);
|
||
|
||
// Ensure expert assignment
|
||
await this.ensureExpertAssignment(requestId);
|
||
|
||
// Add history events
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"EXPERT_COMPLETED_ALL_INFORMATION",
|
||
new Types.ObjectId(expert.sub),
|
||
undefined,
|
||
"expert",
|
||
{
|
||
type: "CAR_BODY",
|
||
hasSecondParty: false,
|
||
},
|
||
);
|
||
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"READY_FOR_SIGNATURES",
|
||
new Types.ObjectId(expert.sub),
|
||
undefined,
|
||
"system",
|
||
);
|
||
|
||
// Read updated request
|
||
const updatedRequest = await this.requestManagementDbService.findOne(
|
||
requestId,
|
||
);
|
||
|
||
return new RequestManagementDtoRs(
|
||
updatedRequest.firstPartyDetails?.firstPartyId,
|
||
null,
|
||
StepsEnum.F_addDescription,
|
||
StepsEnum.AddSignatures,
|
||
"All information completed. Waiting for user signature.",
|
||
updatedRequest._id.toString(),
|
||
);
|
||
} catch (error) {
|
||
this.logger.error("Error in expertCompleteCarBodyForm:", error);
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
throw new InternalServerErrorException(
|
||
"Failed to complete form. Please try again.",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Expert uploads video for expert-initiated file
|
||
* Only one video is needed per file (not per party)
|
||
*/
|
||
async expertUploadVideo(
|
||
expert: any,
|
||
requestId: string,
|
||
file: Express.Multer.File,
|
||
): Promise<RequestManagementDtoRs> {
|
||
if (!file) {
|
||
throw new BadRequestException("Video file is required");
|
||
}
|
||
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
|
||
// Verify this is an expert-initiated file
|
||
if (!request.expertInitiated) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated files",
|
||
);
|
||
}
|
||
|
||
// Verify expert access
|
||
await this.verifyExpertAccess(request, expert);
|
||
|
||
// Check if video already exists
|
||
const stepToCheck = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo;
|
||
if (request.steps?.includes(stepToCheck)) {
|
||
throw new BadRequestException("Video has already been uploaded for this file");
|
||
}
|
||
|
||
try {
|
||
// Create video document
|
||
const videoDocument = await this.blameVideoDbService.create({
|
||
fileName: file.filename,
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
// Determine the step based on request type
|
||
const stepToPush = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo;
|
||
const currentStepValue = stepToPush;
|
||
const nextStepValue = StepsEnum.F_addPlate;
|
||
|
||
// Update request with video
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
{
|
||
$push: { steps: stepToPush },
|
||
$set: {
|
||
currentStep: currentStepValue,
|
||
"firstPartyDetails.firstPartyFile.firstPartyVideoId": videoDocument["_doc"]._id,
|
||
nextStep: nextStepValue,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Add history event
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"EXPERT_UPLOADED_VIDEO",
|
||
new Types.ObjectId(expert.sub),
|
||
undefined,
|
||
"expert",
|
||
{
|
||
videoId: videoDocument["_doc"]._id.toString(),
|
||
fileName: file.filename,
|
||
},
|
||
);
|
||
|
||
return new RequestManagementDtoRs(
|
||
request.firstPartyDetails?.firstPartyId || new Types.ObjectId(expert.sub),
|
||
request?.secondPartyDetails?.secondPartyId || null,
|
||
currentStepValue,
|
||
nextStepValue,
|
||
"Video uploaded successfully",
|
||
request._id,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error("Error uploading expert video:", error);
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
throw new InternalServerErrorException("Failed to upload video");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Expert uploads voice for expert-initiated file
|
||
* Only one voice is needed per file (not per party)
|
||
*/
|
||
async expertUploadVoice(
|
||
expert: any,
|
||
requestId: string,
|
||
voice: Express.Multer.File,
|
||
): Promise<RequestManagementDtoRs> {
|
||
if (!voice) {
|
||
throw new BadRequestException("Voice file is required");
|
||
}
|
||
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
|
||
// Verify this is an expert-initiated file
|
||
if (!request.expertInitiated) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated files",
|
||
);
|
||
}
|
||
|
||
// Verify expert access
|
||
await this.verifyExpertAccess(request, expert);
|
||
|
||
// Check if voice already exists (check both first and second party steps)
|
||
if (
|
||
request.steps?.includes(StepsEnum.F_addVoice) ||
|
||
request.steps?.includes(StepsEnum.S_addVoice)
|
||
) {
|
||
throw new BadRequestException("Voice has already been uploaded for this file");
|
||
}
|
||
|
||
try {
|
||
// Create voice document
|
||
const voiceDoc = await this.blameVoiceDbService.create({
|
||
fileName: voice.filename,
|
||
path: voice.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
context: UploadContext.INITIAL, // Use INITIAL context for expert uploads
|
||
});
|
||
|
||
// Determine current step and next step based on request state
|
||
// If description is already added, next step is different
|
||
const hasDescription = request.steps?.includes(StepsEnum.F_addDescription) ||
|
||
request.steps?.includes(StepsEnum.S_addDescription);
|
||
|
||
const currentStep = request.type === "THIRD_PARTY"
|
||
? StepsEnum.F_addVoice
|
||
: StepsEnum.F_addVoice;
|
||
|
||
const nextStep = hasDescription
|
||
? (request.type === "THIRD_PARTY" ? StepsEnum.AddSignatures : StepsEnum.AddSignatures)
|
||
: (request.type === "THIRD_PARTY" ? StepsEnum.F_addDescription : StepsEnum.F_addDescription);
|
||
|
||
// Update request with voice
|
||
// For expert uploads, we store it in firstPartyFile but it represents the entire file
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
{
|
||
$push: {
|
||
"firstPartyDetails.firstPartyFile.voices": voiceDoc["_id"],
|
||
steps: StepsEnum.F_addVoice,
|
||
},
|
||
$set: {
|
||
currentStep: currentStep,
|
||
nextStep: nextStep,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Add history event
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"EXPERT_UPLOADED_VOICE",
|
||
new Types.ObjectId(expert.sub),
|
||
undefined,
|
||
"expert",
|
||
{
|
||
voiceId: voiceDoc["_id"].toString(),
|
||
fileName: voice.filename,
|
||
},
|
||
);
|
||
|
||
const message = hasDescription
|
||
? "Voice uploaded successfully. File is ready for signatures."
|
||
: "Voice uploaded successfully. Please add description.";
|
||
|
||
return new RequestManagementDtoRs(
|
||
request.firstPartyDetails?.firstPartyId || new Types.ObjectId(expert.sub),
|
||
request?.secondPartyDetails?.secondPartyId || null,
|
||
currentStep,
|
||
nextStep,
|
||
message,
|
||
request._id,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error("Error uploading expert voice:", error);
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
throw new InternalServerErrorException("Failed to upload voice");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Expert adds accident fields to expert-initiated file
|
||
* Can be used for both IN_PERSON and LINK-based files
|
||
*/
|
||
async expertAddAccidentFields(
|
||
expert: any,
|
||
requestId: string,
|
||
fields: { accidentWay: any; accidentReason: any; accidentType: any },
|
||
): Promise<{ message: string; requestId: string }> {
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
|
||
// Verify this is an expert-initiated file
|
||
if (!request.expertInitiated) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated files",
|
||
);
|
||
}
|
||
|
||
// Verify expert access
|
||
await this.verifyExpertAccess(request, expert);
|
||
|
||
// Determine which reply field to update
|
||
const isObjection = !!request.expertResendReply;
|
||
const replyField = isObjection
|
||
? "expertSubmitReplyFinal"
|
||
: "expertSubmitReply";
|
||
|
||
// Get existing reply or create new structure
|
||
const existingReply = request[replyField] || {};
|
||
const newReplyObject: any = {
|
||
...existingReply,
|
||
fields: {
|
||
accidentWay: fields.accidentWay,
|
||
accidentReason: fields.accidentReason,
|
||
accidentType: fields.accidentType,
|
||
},
|
||
};
|
||
|
||
// Preserve other fields if they exist (for SubmitReply structure)
|
||
if (existingReply && typeof existingReply === 'object') {
|
||
if ('description' in existingReply) {
|
||
newReplyObject.description = existingReply.description;
|
||
} else {
|
||
newReplyObject.description = "Expert on-site assessment";
|
||
}
|
||
|
||
if ('guiltyUserId' in existingReply) {
|
||
newReplyObject.guiltyUserId = existingReply.guiltyUserId;
|
||
} else {
|
||
newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId;
|
||
}
|
||
|
||
if ('submitTime' in existingReply) {
|
||
newReplyObject.submitTime = existingReply.submitTime;
|
||
} else {
|
||
newReplyObject.submitTime = new Date();
|
||
}
|
||
|
||
if ('firstPartyComment' in existingReply) {
|
||
newReplyObject.firstPartyComment = existingReply.firstPartyComment;
|
||
} else {
|
||
newReplyObject.firstPartyComment = null;
|
||
}
|
||
|
||
if ('secondPartyComment' in existingReply) {
|
||
newReplyObject.secondPartyComment = existingReply.secondPartyComment;
|
||
} else {
|
||
newReplyObject.secondPartyComment = null;
|
||
}
|
||
} else {
|
||
// Create new structure if no existing reply
|
||
newReplyObject.description = "Expert on-site assessment";
|
||
newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId;
|
||
newReplyObject.submitTime = new Date();
|
||
newReplyObject.firstPartyComment = null;
|
||
newReplyObject.secondPartyComment = null;
|
||
}
|
||
|
||
// Update the file
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
{
|
||
$set: {
|
||
[replyField]: newReplyObject,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Add history event
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"EXPERT_ADDED_ACCIDENT_FIELDS",
|
||
new Types.ObjectId(expert.sub),
|
||
undefined,
|
||
"expert",
|
||
{
|
||
hasObjection: isObjection,
|
||
},
|
||
);
|
||
|
||
return {
|
||
message: "Accident fields added successfully",
|
||
requestId: requestId,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Check if request is expert-initiated and should be auto-assigned
|
||
*/
|
||
async ensureExpertAssignment(requestId: string): Promise<void> {
|
||
const request = await this.requestManagementDbService.findOne(requestId);
|
||
|
||
if (request?.expertInitiated && request.initiatedBy) {
|
||
// Ensure the file is assigned to the initiating expert
|
||
if (
|
||
!request.assignedExpertId ||
|
||
String(request.assignedExpertId) !== String(request.initiatedBy)
|
||
) {
|
||
await this.requestManagementDbService.findAndUpdate(
|
||
{ _id: requestId },
|
||
{
|
||
$set: {
|
||
assignedExpertId: request.initiatedBy,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Add history event
|
||
await this.addHistoryEvent(
|
||
requestId,
|
||
"AUTO_ASSIGNED_TO_EXPERT",
|
||
request.initiatedBy,
|
||
undefined,
|
||
"system",
|
||
{
|
||
expertId: request.initiatedBy.toString(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get or create a user by phone number
|
||
* Used for IN_PERSON expert-initiated files where expert provides phone numbers
|
||
*/
|
||
private async getOrCreateUserByPhoneNumber(phoneNumber: string): Promise<Types.ObjectId> {
|
||
// Try to find existing user by phone number (username or mobile)
|
||
let user = await this.userDbService.findOne({
|
||
$or: [
|
||
{ username: phoneNumber },
|
||
{ mobile: phoneNumber },
|
||
],
|
||
});
|
||
|
||
if (user) {
|
||
return new Types.ObjectId((user as any)._id);
|
||
}
|
||
|
||
// User doesn't exist, create a new one
|
||
const newUser = await this.userDbService.createUser({
|
||
mobile: phoneNumber,
|
||
username: phoneNumber,
|
||
otp: "",
|
||
tokens: {
|
||
token: "",
|
||
rfToken: "",
|
||
},
|
||
fullName: "",
|
||
nationalCode: "",
|
||
lastLogin: new Date(),
|
||
clientKey: new Types.ObjectId(),
|
||
birthDay: "",
|
||
city: "",
|
||
address: "",
|
||
state: "",
|
||
otpExpire: 0,
|
||
});
|
||
|
||
return new Types.ObjectId((newUser as any)._id);
|
||
}
|
||
|
||
/**
|
||
* V2: Get list of documents/items the current user needs to resend
|
||
*/
|
||
async getResendRequirementsV2(
|
||
requestId: string,
|
||
userId: string,
|
||
): Promise<UserResendResponseDto> {
|
||
const request = await this.blameRequestDbService.findById(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Request not found");
|
||
}
|
||
|
||
// Check if status is WAITING_FOR_DOCUMENT_RESEND
|
||
if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) {
|
||
throw new BadRequestException(
|
||
"This request is not waiting for document resend",
|
||
);
|
||
}
|
||
|
||
// Find the current user's party
|
||
const parties = request.parties || [];
|
||
const userParty = parties.find(
|
||
(p) => String(p.person?.userId) === String(userId),
|
||
);
|
||
|
||
if (!userParty) {
|
||
throw new ForbiddenException(
|
||
"You are not a party in this request",
|
||
);
|
||
}
|
||
|
||
// Find resend request for this party (partyId matches person.userId)
|
||
const resendParties = request.expert?.resend?.parties || [];
|
||
const userResendRequest = resendParties.find(
|
||
(r) => String(r.partyId) === String(userParty.person?.userId),
|
||
);
|
||
|
||
if (!userResendRequest) {
|
||
throw new NotFoundException(
|
||
"No resend request found for your party. Please contact support.",
|
||
);
|
||
}
|
||
|
||
return {
|
||
requestId: String(request._id),
|
||
requestedItems: userResendRequest.requestedItems,
|
||
description: userResendRequest.description || "",
|
||
completed: userResendRequest.completed || false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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[];
|
||
},
|
||
): 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) === 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 = userResendRequest.requestedItems || [];
|
||
|
||
// Validate uploaded files match requested items
|
||
const uploadedItems: string[] = [];
|
||
const updatePayload: any = { $set: {} };
|
||
|
||
for (const fieldName in files) {
|
||
const fileArray = files[fieldName];
|
||
if (!fileArray || fileArray.length === 0) continue;
|
||
|
||
const file = fileArray[0];
|
||
|
||
// Check if this item was requested
|
||
if (!requestedItems.includes(fieldName)) {
|
||
throw new BadRequestException(
|
||
`${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`,
|
||
);
|
||
}
|
||
|
||
// Handle different file types
|
||
if (fieldName === "voice") {
|
||
// Create voice record
|
||
const voiceDoc = await this.blameVoiceDbService.create({
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
fileName: file.filename,
|
||
context: "EXPERT_RESEND" as any,
|
||
});
|
||
// Add voice to party evidence
|
||
updatePayload.$addToSet = updatePayload.$addToSet || {};
|
||
updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] =
|
||
(voiceDoc as any)._id;
|
||
uploadedItems.push(fieldName);
|
||
} else if (fieldName === "video") {
|
||
// Create video record
|
||
const videoDoc = await this.blameVideoDbService.create({
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
fileName: file.filename,
|
||
});
|
||
// Set video in party evidence
|
||
updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] =
|
||
String((videoDoc as any)._id);
|
||
uploadedItems.push(fieldName);
|
||
} else {
|
||
// Document types (nationalCertificate, carCertificate, etc.)
|
||
const docType = fieldName as BlameDocumentType;
|
||
const doc = await this.blameDocumentDbService.create({
|
||
path: file.path,
|
||
requestId: new Types.ObjectId(requestId),
|
||
fileName: file.filename,
|
||
documentType: docType,
|
||
});
|
||
// Store document reference (can be stored in a resend-specific field or added to party documents)
|
||
// For now, store in expert.resend.parties[].uploadedDocuments
|
||
updatePayload.$set[
|
||
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
|
||
] = (doc as any)._id;
|
||
uploadedItems.push(fieldName);
|
||
}
|
||
}
|
||
|
||
if (uploadedItems.length === 0) {
|
||
throw new BadRequestException("No files were uploaded");
|
||
}
|
||
|
||
// Check if all requested items are now uploaded
|
||
const allItemsCompleted = requestedItems.every((item) =>
|
||
uploadedItems.includes(item),
|
||
);
|
||
|
||
if (allItemsCompleted) {
|
||
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true;
|
||
updatePayload.$set[`expert.resend.parties.${resendIndex}.completedAt`] =
|
||
new Date();
|
||
}
|
||
|
||
// Update the request
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||
|
||
// Check if all parties completed their resend
|
||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
|
||
(p) => p.completed,
|
||
);
|
||
|
||
if (allPartiesCompleted) {
|
||
// All parties completed - move back to WAITING_FOR_EXPERT
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||
$set: {
|
||
status: CaseStatus.WAITING_FOR_EXPERT,
|
||
"workflow.currentStep": "WAITING_FOR_GUILT_DECISION",
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": "WAITING_FOR_DOCUMENT_RESEND",
|
||
},
|
||
});
|
||
|
||
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
|
||
*/
|
||
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,
|
||
);
|
||
}
|
||
|
||
// Validate expert decision exists
|
||
if (!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,
|
||
};
|
||
|
||
// Update party confirmation
|
||
const updatePayload: any = {
|
||
$set: {
|
||
[`parties.${partyIndex}.confirmation`]: {
|
||
partyRole: userParty.role,
|
||
accepted: isAccept,
|
||
signature: signatureData,
|
||
},
|
||
},
|
||
};
|
||
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||
|
||
// Check if both parties have signed
|
||
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) {
|
||
// Check if both accepted
|
||
const allAccepted = updatedRequest.parties.every(
|
||
(p) => p.confirmation?.accepted === true,
|
||
);
|
||
|
||
if (allAccepted) {
|
||
// Both parties accepted - case is completed
|
||
finalStatus = CaseStatus.COMPLETED;
|
||
message = "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",
|
||
},
|
||
});
|
||
} else {
|
||
// At least one party rejected - needs in-person resolution
|
||
finalStatus = CaseStatus.CANCELLED; // or a specific "NEEDS_IN_PERSON" status
|
||
message =
|
||
"One or both parties rejected the decision. Case requires in-person resolution.";
|
||
|
||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||
$set: {
|
||
status: CaseStatus.CANCELLED,
|
||
"workflow.currentStep": "COMPLETED",
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
|
||
},
|
||
});
|
||
}
|
||
|
||
this.logger.log(
|
||
`All parties signed for request ${requestId}. Final status: ${finalStatus}`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
requestId: String(request._id),
|
||
accepted: isAccept,
|
||
status: finalStatus,
|
||
message,
|
||
};
|
||
}
|
||
}
|