1
0
forked from Yara724/api
Files
yara724-api/src/expert-claim/expert-claim.service.ts

3858 lines
128 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
import { createReadStream, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { HttpService } from "@nestjs/axios";
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
import { Types } from "mongoose";
import { lastValueFrom } from "rxjs";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
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 { SandHubService } from "src/sand-hub/sand-hub.service";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import {
assertClaimCaseForTenant,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
claimCaseStatusToReportBucket,
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
import {
buildMutualAgreementExpertDecision,
enrichBlamePartiesForAgreementView,
} from "src/helpers/blame-party-agreement-decision";
import { resendRequestHasPayload } from "src/helpers/claim-expert-resend";
import {
claimCaseStatusAfterExpertReplyV2,
classifyV2ExpertPricingParts,
claimIsAwaitingExpertFactorValidationV2,
} from "src/helpers/claim-v2-expert-reply-workflow";
import {
getClaimCarAngleCaptureBlob,
getDamagedPartCaptureBlob,
hasDamagedPartCapture,
type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media";
import {
catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray,
normalizeCarPartDamageForExpertReply,
normalizeDamageSelectedParts,
partIdentityKey,
partIdentityKeyFromCarPartDamage,
} from "src/helpers/outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */
const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000;
@Injectable()
export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name);
/** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */
private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000;
private readonly priceDropPart = {
backFender: {
Minor: 2,
Moderate: 3,
Severe: 5,
},
backDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontFender: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontBumper: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
Hood: {
Minor: 2,
moderate: 3,
Severe: 4,
},
Trunk: {
minor: 1,
moderate: 3,
Severe: 5,
},
Roof: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
coil: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
column: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
frontTray: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
backTray: {
Minor: 2,
moderate: 4,
Severe: 5,
},
frontChassis: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
backChassis: {
Minor: 2,
Moderate: 4,
Severe: 6,
},
carFootrest: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
carFloor: {
Minor: 4,
moderate: 6,
Severe: 8,
},
};
private readonly yearCoefficient = {
1393: 2.05,
1394: 2.1,
1395: 2.2,
1396: 2.3,
1397: 2.4,
1398: 2.5,
1399: 2.6,
1400: 2.7,
1401: 2.8,
1402: 2.9,
1403: 3,
1404: 3,
};
constructor(
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly damageImageDbService: DamageImageDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly branchDbService: BranchDbService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userDbService: UserDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimSignDbService: ClaimSignDbService,
) { }
/**
* Resolve a `claim-sign` document id to a downloadable file URL.
* Mirrors the helper used in `ExpertInsurerService` so signature links are
* exposed identically across the two expert-facing detail endpoints.
*/
private async claimSignLinkFromId(
signDetailId: unknown,
): Promise<string | undefined> {
if (signDetailId == null || signDetailId === "") return undefined;
const id = String(signDetailId);
if (!Types.ObjectId.isValid(id)) return undefined;
const doc = await this.claimSignDbService.findOne({
_id: new Types.ObjectId(id),
});
const path = (doc as any)?.path;
return path ? buildFileLink(String(path)) : undefined;
}
/**
* Build a deep-cloned `evaluation` payload with resolved file URLs the
* front-end can render directly:
*
* - `damageExpertReply.userComment.signLink`
* - `damageExpertReplyFinal.userComment.signLink`
* - `ownerInsurerApproval.signLink`
* - `ownerPricedPartsApproval.signLink`
* - `damageExpertReply.parts[i].factorLink` (ObjectId → URL)
* - `damageExpertReplyFinal.parts[i].factorLink` (ObjectId → URL)
*
* Returns `undefined` when there's no evaluation to enrich.
*/
private async enrichClaimEvaluationForExpert(
evaluation: Record<string, unknown> | undefined | null,
): Promise<Record<string, unknown> | undefined> {
if (!evaluation || typeof evaluation !== "object") return undefined;
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<string, unknown>;
const enrichReply = async (key: string) => {
const reply = ev[key] as Record<string, unknown> | undefined;
if (!reply) return;
const uc = reply.userComment as Record<string, unknown> | undefined;
if (uc?.signDetailId != null) {
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
reply.userComment = { ...uc, signLink };
}
// Resolve each `parts[].factorLink` ObjectId to a downloadable URL.
// `populateFactorLinks` mutates in place — safe here because `ev` was
// deep-cloned via JSON above, so we never touch the live mongoose doc.
await this.populateFactorLinks(reply);
};
await enrichReply("damageExpertReply");
await enrichReply("damageExpertReplyFinal");
const enrichApproval = async (key: string) => {
const o = ev[key] as Record<string, unknown> | undefined;
if (o?.signDetailId != null) {
const signLink = await this.claimSignLinkFromId(o.signDetailId);
ev[key] = { ...o, signLink };
}
};
await enrichApproval("ownerInsurerApproval");
await enrichApproval("ownerPricedPartsApproval");
return ev;
}
/** Load immutable profile fields from `damage-expert` for the acting expert. */
private async snapshotDamageExpert(actorId: string) {
const doc = await this.damageExpertDbService.findById(actorId);
return snapshotFromDamageExpert(doc as DamageExpertModel);
}
private async recordClaimExpertActivity(args: {
expertId: string;
tenantId: string;
claimId: string;
eventType: ExpertFileActivityType;
idempotencyKey?: string;
}): Promise<void> {
if (
!Types.ObjectId.isValid(args.expertId) ||
!Types.ObjectId.isValid(args.tenantId) ||
!Types.ObjectId.isValid(args.claimId)
) {
return;
}
await this.expertFileActivityDbService.recordEvent({
expertId: args.expertId,
tenantId: args.tenantId,
fileId: args.claimId,
fileType: ExpertFileKind.CLAIM,
eventType: args.eventType,
idempotencyKey: args.idempotencyKey,
});
}
/**
* Insurer tenant id stored on claim file-activity rows. Prefer `owner.userClientKey`
* so V2 events line up with `expireClaimWorkflowLockV2IfStale` and insurer `findByTenant`.
*/
private claimActivityTenantId(
claim: { owner?: { userClientKey?: unknown } } | null | undefined,
actor: { clientKey?: string },
): string {
const fromOwner = claim?.owner?.userClientKey;
if (fromOwner != null && String(fromOwner).length > 0) {
return String(fromOwner);
}
return String(actor.clientKey ?? "");
}
/**
* `preLockQueueSnapshot` must only store {@link ClaimWorkflowStep} values.
* Legacy / mistaken data sometimes put {@link ClaimCaseStatus} (e.g. WAITING_FOR_DAMAGE_EXPERT)
* in `workflow.currentStep`, which would fail Mongoose enum validation when snapshotted.
*/
private normalizeClaimWorkflowStepForSnapshot(
value: unknown,
fallback: ClaimWorkflowStep,
): ClaimWorkflowStep {
if (
typeof value === "string" &&
(Object.values(ClaimWorkflowStep) as string[]).includes(value)
) {
return value as ClaimWorkflowStep;
}
return fallback;
}
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined;
const ownerUserId = String(claim.owner.userId);
if (claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(),
);
const ownerParty = (blame?.parties || []).find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === ownerUserId,
);
const fromParty = ownerParty?.person?.phoneNumber;
if (fromParty && typeof fromParty === "string") return fromParty;
}
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserId),
});
const m = user?.mobile;
if (m && typeof m === "string") return m;
return undefined;
}
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
private expertVehicleFromPartyVehicle(v: any): {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
} | undefined {
if (!v || typeof v !== "object") return undefined;
const carName = v.name ?? v.carName;
const carModel = v.model ?? v.carModel;
const carType = v.type ?? v.carType;
const plateId = v.plateId;
const plate =
plateId != null && String(plateId).length > 0
? { plateId: String(plateId) }
: undefined;
if (!carName && !carModel && !carType && !plate) return undefined;
return { carName, carModel, carType, ...(plate ? { plate } : {}) };
}
/** Damaged party vehicle on blame (matches claim owner, or non-guilty party for THIRD_PARTY). */
private damagedPartyVehicleFromBlame(
blame: any,
claim: any,
):
| {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
}
| undefined {
const parties = blame?.parties;
if (!Array.isArray(parties) || parties.length === 0) return undefined;
const ownerId = claim?.owner?.userId ? String(claim.owner.userId) : "";
let party = ownerId
? parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === ownerId,
)
: undefined;
if (!party && blame?.expert?.decision?.guiltyPartyId) {
const guilty = String(blame.expert.decision.guiltyPartyId);
party = parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) !== guilty,
);
}
if (!party && parties.length === 1) party = parties[0];
return this.expertVehicleFromPartyVehicle(party?.vehicle);
}
private vehicleForExpertFromClaimAndBlameMap(
claim: any,
blameById: Map<string, any>,
):
| { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string } }
| undefined {
const cv = claim?.vehicle;
if (
cv &&
(cv.carName || cv.carModel || cv.carType || (cv as any).plate)
) {
return {
carName: cv.carName,
carModel: cv.carModel,
carType: cv.carType,
...((cv as any).plate ? { plate: (cv as any).plate } : {}),
};
}
const bid = claim?.blameRequestId?.toString();
if (!bid) return undefined;
const blame = blameById.get(bid);
if (!blame) return undefined;
return this.damagedPartyVehicleFromBlame(blame, claim);
}
/**
* Blame file kind for damage-expert UI: THIRD_PARTY vs CAR_BODY, and CAR_BODY first-step flags.
*/
private blameFileContextForExpert(blame: any): {
blameRequestType?: BlameRequestType;
carBodyFirstForm?: { car?: boolean; object?: boolean };
blameStatus?: string
} {
if (!blame?.type) return {};
const blameRequestType = blame.type as BlameRequestType;
const out: {
blameRequestType?: BlameRequestType;
carBodyFirstForm?: { car?: boolean; object?: boolean };
blameStatus?: string;
} = { blameRequestType };
out.blameStatus = blame.blameStatus;
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
const parties = blame.parties;
if (!Array.isArray(parties) || parties.length === 0) return out;
const first =
parties.find((p: any) => p?.role === PartyRole.FIRST) ?? parties[0];
const cbf = first?.carBodyFirstForm;
delete out.blameStatus
if (cbf && typeof cbf === "object") {
out.carBodyFirstForm = {
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}),
};
}
return out;
}
private isVisibleToClientType(client: any, actor: any): boolean {
if (actor.userType === UserType.GENUINE) {
return true;
}
if (
actor.userType === UserType.LEGAL &&
String(client._id) === actor.clientKey
) {
return true;
}
return false;
}
private wasHandledByActor(request: any, actorSub: string): boolean {
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
if (!Array.isArray(actorChecker)) {
return false;
}
const matchingEntry = actorChecker.find(
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
);
return !!matchingEntry;
}
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
// const requests = await this.claimRequestManagementDbService.findAllByStatus(
// {
// claimStatus: {
// $in: [
// ReqClaimStatus.UnChecked,
// ReqClaimStatus.ReviewRequest,
// ReqClaimStatus.CheckAgain,
// ReqClaimStatus.CloseRequest,
// ReqClaimStatus.InPersonVisit,
// ReqClaimStatus.CheckedRequest,
// ReqClaimStatus.PendingFactorValidation,
// ],
// },
// "damageExpertReply.actorDetail.actorId":actor.sub
// },
// );
const requests = await this.claimRequestManagementDbService.findAllByStatus(
{
$or: [
{
claimStatus: ReqClaimStatus.UnChecked,
},
{
claimStatus: {
$in: [
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
],
},
"damageExpertReply.actorDetail.actorId": actor.sub,
},
],
}
);
const filteredRequests = [];
for (const r of requests) {
// For expert-initiated blame files, only show to the initiating expert
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
if (String(r.blameFile.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated claim files are always visible to the initiating expert
filteredRequests.push(r);
continue;
}
const client = await this.clientDbService.findOne({
_id: r.userClientKey,
});
if (!client) {
this.logger.warn(
`Client not found for claim request with ID: ${r._id}. Skipping.`,
);
continue;
}
const specialHandlingStatuses = [
ReqClaimStatus.CheckAgain,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation,
];
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
}
}
}
if (filteredRequests.length === 0) {
throw new NotFoundException(
"No claim requests found for you at this time.",
);
}
return new ClaimListDtoRs(filteredRequests);
}
public unlockApi(request, timer) {
// ADD to project
return setTimeout(() => {
this.claimRequestManagementDbService.findOne(request._id).then((r) => {
const updateExp = {
lockFile: false,
unlockTime: null,
actorLocked: {},
};
if (r?.claimStatus == ReqClaimStatus.ReviewRequest) {
Object.assign(updateExp, {
claimStatus: ReqClaimStatus.UnChecked,
});
}
this.claimRequestManagementDbService.findOneAndUpdate(request._id, {
...updateExp,
});
});
this.logger.log(`unlock this request : ${request._id}`);
// TODO enum request claimStatus create
}, timer);
}
async lockClaimRequest(requestId: string, actorDetail) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new BadRequestException("Claim request not found");
const isLocked = !!request.lockFile;
const lockedByCurrent =
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Claim request is locked by another expert");
}
if (request.claimStatus === ReqClaimStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because claimStatus is UserPending",
);
}
if (
request.damageExpertReplyFinal &&
request.claimStatus === ReqClaimStatus.CheckedRequest
) {
throw new BadRequestException("Request has damage expert reply");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
await this.claimRequestManagementDbService.findOneAndUpdate(
new Types.ObjectId(requestId),
{
lockFile: true,
claimStatus: ReqClaimStatus.ReviewRequest,
unlockTime: fifteenMinutes,
lockTime: Date.now(),
$set: {
actorLocked: {
actorId: new Types.ObjectId(actorDetail.sub),
fullName: actorDetail.fullName,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
$push: {
actorsChecker: {
[ReqClaimStatus.ReviewRequest]: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
},
Date: new Date(),
},
},
},
);
await this.recordClaimExpertActivity({
expertId: String(actorDetail.sub),
tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""),
claimId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`,
});
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
return { _id: requestId, lock: true };
}
private normalizeText(str: string) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/[\u064B-\u065F]/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
normalizeKey(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
}
parsePersianNumber(input: string): number {
return Number(
input
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/,/g, ""),
);
}
/** Factor validation: expert must send a line amount (OCR is not used for factor photos). */
private expertFactorValidationDecisionHasLinePricing(decision: {
totalPayment?: string;
price?: string;
salary?: string;
}): boolean {
const tp =
decision.totalPayment != null &&
String(decision.totalPayment).trim() !== "";
const pr =
decision.price != null && String(decision.price).trim() !== "";
const sa =
decision.salary != null && String(decision.salary).trim() !== "";
return tp || (pr && sa);
}
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
private claimReplyPartLineTotalForCap(part: {
partId?: string;
totalPayment?: unknown;
price?: unknown;
salary?: unknown;
}): number {
const tpRaw =
part.totalPayment != null ? String(part.totalPayment).trim() : "";
if (tpRaw !== "") {
return this.parsePersianNumber(tpRaw);
}
const pr = this.parsePersianNumber(String(part.price ?? "0"));
const sa = this.parsePersianNumber(String(part.salary ?? "0"));
return pr + sa;
}
/** Same rules as V1 `submitReplyRequest`: daghi option + conditional price / branchId; `branchId` stored as ObjectId. */
private validateAndNormalizeDaghiForExpertReplyV2(
parts: import("./dto/expert-claim-v2.dto").PartPricingV2Dto[],
) {
for (const part of parts) {
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
);
}
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) {
throw new BadRequestException(
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
);
}
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) {
throw new BadRequestException(
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
);
}
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
);
}
}
}
return parts.map((part) => ({
...part,
daghi: {
option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && {
branchId: new Types.ObjectId(part.daghi.branchId),
}),
},
}));
}
private findClosestCar(input: string, cars: { carName: string }[]) {
if (!input || !cars || cars.length === 0) {
this.logger.debug(
`[PriceDrop] findClosestCar: Invalid input. input: ${input}, cars length: ${cars?.length || 0}`,
);
return null;
}
const normalizedInput = this.normalizeText(input);
if (!normalizedInput) {
this.logger.debug(
`[PriceDrop] findClosestCar: Could not normalize input "${input}"`,
);
return null;
}
let bestMatch = null;
let minDistance = Infinity;
let validCarsChecked = 0;
for (const car of cars) {
if (!car || !car.carName) {
continue;
}
const normalizedCarName = this.normalizeText(car.carName);
if (!normalizedCarName) {
continue;
}
validCarsChecked++;
const dist = stringDistance(normalizedInput, normalizedCarName);
if (dist < minDistance) {
minDistance = dist;
bestMatch = car;
}
}
if (!bestMatch) {
this.logger.debug(
`[PriceDrop] findClosestCar: No match found for "${input}" after checking ${validCarsChecked} valid cars out of ${cars.length} total`,
);
} else {
this.logger.debug(
`[PriceDrop] findClosestCar: Best match for "${input}" is "${bestMatch.carName}" with distance ${minDistance}`,
);
}
return bestMatch;
}
private priceDropFormula(
carPrice: number | string,
carModel: number,
carValue: number[],
) {
let normalizePrice: number;
if (typeof carPrice === "string") {
normalizePrice = this.parsePersianNumber(carPrice);
} else if (typeof carPrice === "number") {
normalizePrice = carPrice;
} else {
this.logger.warn(
`Invalid carPrice type received in formula: ${typeof carPrice}`,
);
normalizePrice = 0;
}
const coefficient = this.yearCoefficient[carModel] || 1;
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
const totalDrop = (normalizePrice * coefficient * sumOfSeverity) / 400;
return {
carPrice: normalizePrice,
carModel: carModel || null,
carValue: carValue || [],
sumOfSeverity: sumOfSeverity || 0,
coefficientYear: coefficient || 1,
total: totalDrop || 0,
};
}
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
const requestId = request?._id?.toString() || 'unknown';
try {
// Step 1: Check SandHub data
if (!request?.blameFile?.sanHubId) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing sanHubId in blameFile. Cannot calculate price drop.`,
);
return;
}
const sandHubData = await this.sandHubService.getSandHubDataFromId(
request.blameFile.sanHubId,
);
if (!sandHubData) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not retrieve SandHub data for sanHubId: ${request.blameFile.sanHubId}. Cannot calculate price drop.`,
);
return;
}
if (sandHubData.MapTypNam === undefined || sandHubData.MapTypNam === null) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`,
);
return;
}
const existingPriceDrop = request.priceDrop || {};
const manualOverride = query || {};
// Step 2: Check car parts and get severity values
if (!carPartsAi || (Array.isArray(carPartsAi) && carPartsAi.length === 0)) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`,
);
}
const severityValues = this.getSeverityValues(carPartsAi);
let autoCalculatedData: any = {};
if (severityValues.length === 0) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No severity values found from car parts. Price drop calculation will use existing data or manual override only.`,
);
}
// Step 3: Check car detail and find closest car
if (!request?.carDetail) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carDetail in request. Cannot find car price.`,
);
} else if (!request.carDetail.carName) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carName in carDetail. Cannot find car price.`,
);
} else if (severityValues.length > 0) {
// Only fetch car prices if we have severity values to calculate
const carPrices = await this.fetchCarPrices();
if (!carPrices || carPrices.length === 0) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
);
const closestCar = this.findClosestCar(
request.carDetail.carName,
carPrices,
);
if (!closestCar) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not find closest matching car for "${request.carDetail.carName}". Available cars: ${carPrices.length}. Cannot determine car price.`,
);
} else if (!closestCar.marketPrice) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" but marketPrice is missing. Cannot calculate price drop.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
);
autoCalculatedData = {
carPrice: closestCar.marketPrice,
carValue: severityValues,
};
}
}
}
// Step 4: Merge and recalculate
const finalPriceDropData = this.mergeAndRecalculatePriceDrop(
existingPriceDrop,
autoCalculatedData,
manualOverride,
sandHubData.ModelCii,
);
if (!finalPriceDropData) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to generate final price drop data.`,
);
return;
}
// Step 5: Log calculation result
if (!finalPriceDropData.carPrice || !finalPriceDropData.carValue || finalPriceDropData.carValue.length === 0) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Price drop calculated but missing critical data. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
`carValue length: ${finalPriceDropData.carValue?.length || 0}, ` +
`total: ${finalPriceDropData.total}`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Successfully calculated price drop. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
`sumOfSeverity: ${finalPriceDropData.sumOfSeverity}, ` +
`total: ${finalPriceDropData.total}`,
);
}
// Step 6: Save to database
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(request._id) },
{ $set: { priceDrop: finalPriceDropData } },
);
this.logger.log(
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
);
} catch (err) {
this.logger.error(
`[PriceDrop] Request ${requestId}: An error occurred during the price drop calculation: ${err.message}`,
err.stack,
);
}
}
private mergeAndRecalculatePriceDrop(
existingData: any,
autoCalculatedData: any,
manualOverride: any,
carModel: number,
) {
const mergedInputs = {
...existingData,
...autoCalculatedData,
...manualOverride,
};
if (!mergedInputs.carPrice || !mergedInputs.carValue) {
return {
carPrice: mergedInputs.carPrice || null,
carModel: carModel,
carValue: mergedInputs.carValue || [],
sumOfSeverity: 0,
coefficientYear: this.yearCoefficient[carModel] || 1,
total: 0,
};
}
return this.priceDropFormula(
mergedInputs.carPrice,
carModel,
mergedInputs.carValue,
);
}
private getSeverityValues(carPartsAi: any[]): number[] {
const severities: number[] = [];
const knownPartKeyMap = new Map<string, string>();
for (const originalKey of Object.keys(this.priceDropPart)) {
if (typeof originalKey === "string") {
knownPartKeyMap.set(this.normalizeKey(originalKey), originalKey);
}
}
for (const part of carPartsAi) {
const damagedPartTable = part?.aiReport?.damaged_part_table;
if (Array.isArray(damagedPartTable) && damagedPartTable.length > 0) {
for (const damagedPartInfo of damagedPartTable) {
if (damagedPartInfo && typeof damagedPartInfo.name === "string") {
const normalizedAiPartName = this.normalizeKey(
damagedPartInfo.name,
);
if (knownPartKeyMap.has(normalizedAiPartName)) {
const originalPriceDropKey =
knownPartKeyMap.get(normalizedAiPartName);
const severityValue =
this.priceDropPart[originalPriceDropKey]?.[
damagedPartInfo.severity
];
if (severityValue !== undefined) {
severities.push(severityValue);
}
}
}
}
}
}
return severities;
}
private async fetchCarPrices(): Promise<
{ carName: string; marketPrice: number }[]
> {
const carPrices: { carName: string; marketPrice: number }[] = [];
const endpoints = ["akharin", "hamrah"];
for (const item of endpoints) {
try {
const url = `${process.env.CW_URL}price?${item}`;
const response = await lastValueFrom(
this.httpService.get(url),
);
if (Array.isArray(response.data)) {
let validEntries = 0;
for (const r of response.data) {
if (r?.carName && r?.marketPrice) {
carPrices.push({
carName: r.carName,
marketPrice: r.marketPrice,
});
validEntries++;
}
}
this.logger.debug(
`[PriceDrop] Fetched ${validEntries} valid car prices from endpoint '${item}' (total entries: ${response.data.length})`,
);
} else {
this.logger.warn(
`[PriceDrop] Endpoint '${item}' returned non-array data: ${typeof response.data}`,
);
}
} catch (err) {
this.logger.error(
`[PriceDrop] Error fetching car prices for endpoint '${item}': ${err.message}`,
err.stack,
);
}
}
if (carPrices.length === 0) {
this.logger.error(
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(', ')}`,
);
} else {
this.logger.debug(
`[PriceDrop] Total car prices fetched: ${carPrices.length}`,
);
}
return carPrices;
}
private calculateFinalDropPrice(
closestCar: { marketPrice: number } | null,
carModel: any,
severities: number[],
query?: any,
) {
if (query?.carPrice && query?.carValue) {
return this.priceDropFormula(query.carPrice, carModel, query.carValue);
}
if (closestCar?.marketPrice && severities.length > 0) {
return this.priceDropFormula(
closestCar.marketPrice,
carModel,
severities,
);
}
return null;
}
async requestPerId(requestId: string, currentUser: any, query?: any) {
try {
// 1. Fetch the initial request document and perform calculations
const initialRequest =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!initialRequest) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
await this.calculatePriceDrop(
initialRequest,
initialRequest.imageRequired.selectPartOfCar,
query,
);
const requestUpdated =
await this.claimRequestManagementDbService.findOneDocument(requestId);
await this.populateFactorLinks(requestUpdated.damageExpertReply);
await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal);
// 2. Populate required documents with file links
if (requestUpdated.requiredDocuments && Object.keys(requestUpdated.requiredDocuments).length > 0) {
for (const documentType in requestUpdated.requiredDocuments) {
const documentId = requestUpdated.requiredDocuments[documentType];
if (documentId) {
try {
const doc = await this.claimRequiredDocumentDbService.findById(
documentId.toString(),
);
if (doc && doc.path) {
// Replace ID with file URL
requestUpdated.requiredDocuments[documentType] = buildFileLink(doc.path) as any;
}
} catch (error) {
this.logger.warn(
`Failed to populate required document ${documentType}: ${error.message}`,
);
// Keep the ID if population fails
}
}
}
}
// 3. Populate the resent document and voice links from the nested blameFile.
if (requestUpdated.blameFile?.expertResendReply) {
// Helper function to avoid repeating code
const populatePartyLinks = async (
partyKey: "firstParty" | "secondParty",
) => {
const partyReply =
requestUpdated.blameFile.expertResendReply[partyKey];
if (!partyReply) return;
// Populate the voice link
if (partyReply.voice) {
const voiceDoc = await this.blameVoiceDbService.findOne(
partyReply.voice.toString(),
);
if (voiceDoc) {
partyReply.voice = buildFileLink(voiceDoc.path);
}
}
// Populate the document links
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
// This line will now work correctly after you inject the service.
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) {
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
}
}
}
}
};
// Run the population for both parties
await populatePartyLinks("firstParty");
await populatePartyLinks("secondParty");
}
// 4. Perform authorization checks (as before)
if (this.isCurrentUserAllowed(requestUpdated, currentUser)) {
return new ClaimPerIdRs(requestUpdated);
}
// Check if locked by someone else (allow if locked by current user)
if (this.isRequestLocked(requestUpdated, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// 5. Return the fully populated response
return new ClaimPerIdRs(requestUpdated);
} catch (error) {
this.globalErrHandler(error);
}
}
async imageRequired(requestId, currentUser) {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
if (this.isCurrentUserAllowed(request, currentUser)) {
return (await this.claimRequestManagementDbService.findOne(requestId))
.imageRequired;
} else if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
} else {
throw new HttpException(
{ responseCode: 1008, message: "notAllowed is locked" },
HttpStatus.FORBIDDEN,
);
}
}
private async populateFactorLinks(reply: any): Promise<void> {
if (!reply || !Array.isArray(reply.parts)) {
return;
}
for (const part of reply.parts) {
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
const factorDoc = await this.claimFactorsImageDbService.findById(
part.factorLink.toString(),
);
if (factorDoc) {
part.factorLink = buildFileLink(factorDoc.path);
}
}
}
}
async getRequiredDocuments(requestId: string, currentUser: any) {
try {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
// Perform authorization checks - allow if locked by current user
if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// Fetch all required documents for this claim
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
requestId,
);
// Populate with file URLs
const populatedDocuments = documents.map((doc) => ({
_id: doc._id,
documentType: doc.documentType,
fileName: doc.fileName,
fileUrl: buildFileLink(doc.path),
uploadedAt: doc.uploadedAt,
}));
return {
claimId: requestId,
totalDocuments: populatedDocuments.length,
documents: populatedDocuments,
};
} catch (error) {
this.globalErrHandler(error);
}
}
private async processAiImages(
imageRequired: any,
): Promise<{ aroundTheCar: any[]; damagePartOfCar: any[] }> {
const aiImages = { aroundTheCar: [], damagePartOfCar: [] };
return aiImages;
}
private async processImage(imageId: string) {
return this.damageImageDbService.findOne(imageId);
}
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
// Check if locked by current user and lock is still active
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub &&
request.lockFile;
if (!isLockedByCurrentUser) {
return false;
}
// Also check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, but still allow access if they were the one who locked it
// The unlockApi will handle the cleanup
return true;
}
}
return true;
}
private isRequestLocked(request: any, currentUser?: any): boolean {
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
return false;
}
// Check if lock has expired
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, treat as not locked
return false;
}
}
// If currentUser is provided, allow access if they are the one who locked it
if (currentUser) {
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub;
// Return false (not locked) if locked by current user, true if locked by someone else
return !isLockedByCurrentUser;
}
// If no currentUser provided, treat as locked
return true;
}
async submitReplyRequest(
requestId: string,
reply: ClaimSubmitReplyDto,
userId: any,
) {
try {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (
String(request?.actorLocked?.actorId) !== userId.sub &&
!request?.objection
) {
throw new ForbiddenException("Access denied to this request");
}
// Check if lock has expired (unlockTime has passed)
if (request?.unlockTime && !request?.objection) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
throw new ForbiddenException("Your time has expired");
}
} else if (request?.unlockTime == null && !request?.objection) {
throw new ForbiddenException("Your time has expired");
}
if (!request.lockFile && !request?.objection) {
throw new ForbiddenException(
"For submit reply you must lock the request",
);
}
// Validate total price cap (priced lines sum)
if (reply.parts && reply.parts.length > 0) {
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0;
for (const part of reply.parts) {
if (part.totalPayment) {
let partPrice: number;
if (typeof part.totalPayment === "string") {
// Handle Persian numbers and remove commas
partPrice = this.parsePersianNumber(part.totalPayment);
} else if (typeof part.totalPayment === "number") {
partPrice = part.totalPayment;
} else {
this.logger.warn(
`Invalid totalPayment type for part ${part.partId}: ${typeof part.totalPayment}`,
);
partPrice = 0;
}
if (isNaN(partPrice)) {
this.logger.warn(
`Could not parse totalPayment for part ${part.partId}: ${part.totalPayment}`,
);
partPrice = 0;
}
totalPrice += partPrice;
}
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
priceCap: PRICE_CAP,
});
}
}
// Validate daghi fields
if (reply.parts && reply.parts.length > 0) {
for (const part of reply.parts) {
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
);
}
// Validate based on option
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) {
throw new BadRequestException(
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
);
}
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) {
throw new BadRequestException(
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
);
}
// Validate branchId is a valid ObjectId
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
);
}
}
// For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed
}
}
const isObjection = !!request.objection;
const replyFieldKey = isObjection
? "damageExpertReplyFinal"
: "damageExpertReply";
const needsFactorUpload =
reply.parts?.some((part) => part.factorNeeded === true) ?? false;
const nextStatus = needsFactorUpload
? ReqClaimStatus.PendingFactorUpload
: ReqClaimStatus.CheckedRequest;
const nextStep = needsFactorUpload
? ClaimStepsEnum.WaitingForFactorUpload
: ClaimStepsEnum.WaitingForUserToReact;
// Process parts: convert branchId string to ObjectId if present
const processedParts = reply.parts.map((part) => ({
...part,
daghi: {
option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && {
branchId: new Types.ObjectId(part.daghi.branchId),
}),
},
}));
const expertProfileSnapshot = await this.snapshotDamageExpert(userId.sub);
const replyPayload = {
description: reply.description,
parts: processedParts,
submitTime: new Date(),
actorDetail: {
actorName: userId.fullName,
actorId: userId.sub,
},
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
const updatePayload = {
$set: {
lockFile: false,
claimStatus: nextStatus,
currentStep: nextStep,
[replyFieldKey]: replyPayload,
},
$push: {
actorsChecker: {
[ReqClaimStatus.CheckedRequest]: request.actorLocked,
Date: new Date(),
},
steps: nextStep,
},
};
await this.claimRequestManagementDbService.findAndUpdate(
requestId,
updatePayload,
);
if (!isObjection) {
await this.recordClaimExpertActivity({
expertId: String(userId.sub),
tenantId: String(request.userClientKey ?? userId.clientKey ?? ""),
claimId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${requestId}:handled:${userId.sub}`,
});
}
return {
requestId: request._id,
claimStatus: nextStatus,
};
} catch (error) {
this.logger.error(
`Error in submitReplyRequest for claim ${requestId}:`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
"An internal server error occurred.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async submitResend(
requestId: string,
body: ClaimSubmitResendDto,
userId: any,
) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new NotFoundException("request_not_found");
if (String(request?.actorLocked?.actorId) !== userId)
throw new ForbiddenException("access_denied_please_lock");
if (request.unlockTime == null)
throw new ForbiddenException("time_expired");
if (!request.lockFile)
throw new ForbiddenException(
"for submit reply you must lock the request",
);
if (request.damageExpertResend)
throw new ForbiddenException("request already has resend reply");
if (request.damageExpertReplyFinal)
throw new ForbiddenException("request already has final reply");
const resendSnapshot = await this.snapshotDamageExpert(userId);
await this.claimRequestManagementDbService.findAndUpdate(requestId, {
lockFile: false,
claimStatus: ReqClaimStatus.WaitingForUserToResend,
damageExpertResend: {
resendDescription: body.resendDescription,
resendDocuments: body.resendDocuments,
resendCarParts: body.resendCarParts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
$push: {
actorsChecker: {
[ReqClaimStatus.WaitingForUserToResend]: request.actorLocked,
},
Date: new Date(),
},
});
return request;
}
async streamServiceV2(requestId, query, res, header) {
const request = await this.claimCaseDbService.findById(requestId);
// const blameCase = await this.blameCaseDbService
return request;
// let videoPath: string = "";
// switch (query) {
// case "accident":
// videoPath = await this.getAccidentVideoPath(
// String(
// request?.blameFile?.firstPartyDetails?.firstPartyFile
// ?.firstPartyVideoId,
// ),
// );
// break;
// case "car-capture":
// videoPath = await this.getCarCapture(requestId);
// break;
// default:
// throw new NotFoundException("Invalid query type");
// }
// if (!videoPath) throw new NotFoundException("video_not_found");
// const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
// if (!existsSync(absolutePath)) {
// throw new NotFoundException(
// `Video file not found at path: ${absolutePath}`,
// );
// }
// const { size } = statSync(absolutePath);
// const range = header.range;
// if (range) {
// const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
// const start = parseInt(startStr, 10);
// const end = endStr ? parseInt(endStr, 10) : size - 1;
// const chunkSize = end - start + 1;
// const file = createReadStream(absolutePath, { start, end });
// res.writeHead(206, {
// "Content-Range": `bytes ${start}-${end}/${size}`,
// "Accept-Ranges": "bytes",
// "Content-Length": chunkSize,
// "Content-Type": "video/mp4",
// });
// file.pipe(res);
// } else {
// res.writeHead(200, {
// "Content-Length": size,
// "Content-Type": "video/mp4",
// });
// createReadStream(absolutePath).pipe(res);
// }
}
async streamService(requestId, query, res, header) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new NotFoundException("Invalid query type");
}
if (!videoPath) throw new NotFoundException("video_not_found");
const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
if (!existsSync(absolutePath)) {
throw new NotFoundException(
`Video file not found at path: ${absolutePath}`,
);
}
const { size } = statSync(absolutePath);
const range = header.range;
if (range) {
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : size - 1;
const chunkSize = end - start + 1;
const file = createReadStream(absolutePath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "video/mp4",
});
file.pipe(res);
} else {
res.writeHead(200, {
"Content-Length": size,
"Content-Type": "video/mp4",
});
createReadStream(absolutePath).pipe(res);
}
}
async getVideoLink(
requestId: string,
query: "car-capture" | "accident",
): Promise<{ url: string }> {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Claim request not found");
}
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new BadRequestException("Invalid query type specified");
}
if (!videoPath) {
throw new NotFoundException(
"Video file not found for the specified type.",
);
}
// Use your existing helper to build the full, public URL
const fileUrl = buildFileLink(videoPath);
// Return the URL in a clean JSON object
return { url: fileUrl };
}
async voiceStream(id, res, headers) {
// audio/mpeg
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) throw new NotFoundException("video_not_found");
const { size } = statSync(voice.path);
const voicePath = voice.path;
const VoiceRange = headers.range;
if (VoiceRange) {
const parts = VoiceRange?.replace(/bytes=/, "").split("-");
const end = parts[1] ? parseInt(parts[1], 16) : size - 1;
const start = parseInt(parts[0], 10);
const chunksize = end - start + 1;
const file = createReadStream(voicePath, { start, end });
const header = {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunksize,
"Content-Type": "audio/mpeg",
};
res.writeHead(206, header);
file.pipe(res);
} else {
const head = {
"Content-Length": size,
"Content-Type": "audio/mpeg",
};
res.writeHead(200, head);
createReadStream(voicePath).pipe(res);
}
}
async getVoiceLink(id: string): Promise<{ url: string }> {
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) {
throw new NotFoundException("Voice file not found");
}
const fileUrl = buildFileLink(voice.path);
return { url: fileUrl };
}
async getCarCapture(requestId): Promise<string> {
const video = await this.claimVideoCaptureDbService.findOne({
claimId: new Types.ObjectId(requestId),
});
if (!video?.path) {
throw new NotFoundException("Car capture video not found");
}
return String(video.path);
}
async getAccidentVideoPath(requestId: string): Promise<string> {
const video = await this.blameVideoDbService.findOneByRequestId(requestId);
if (!video?.path) {
throw new NotFoundException("Accident video not found");
}
return video.path;
}
async validateClaimFactors(
claimId: string,
decisions: {
partId: string;
status: FactorStatus;
rejectionReason?: string;
}[],
expertId: string,
) {
const claim = await this.claimRequestManagementDbService.findOne(claimId);
if (!claim) {
throw new NotFoundException("Claim not found");
}
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
const replyFieldKey = claim.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
if (!finalReply) {
throw new BadRequestException("No expert reply found in this claim.");
}
for (const decision of decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => p.partId === decision.partId,
);
if (partIndex === -1) {
this.logger.warn(
`Part with ID ${decision.partId} not found in claim ${claimId}. Skipping.`,
);
continue;
}
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(claimId) },
{
$set: {
[`${replyFieldKey}.parts.${partIndex}.factorStatus`]:
decision.status,
[`${replyFieldKey}.parts.${partIndex}.rejectionReason`]:
decision.rejectionReason || null,
},
},
);
}
const factorSnap = await this.snapshotDamageExpert(expertId);
if (factorSnap && decisions.length > 0) {
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(claimId) },
{
$set: { factorValidationExpertProfileSnapshot: factorSnap },
},
);
}
const updatedClaim =
await this.claimRequestManagementDbService.findOne(claimId);
const updatedReply =
updatedClaim.damageExpertReplyFinal || updatedClaim.damageExpertReply;
const requiredFactors = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.PENDING || !p.factorStatus,
);
let newStatus: ReqClaimStatus | null = null;
let responseMessage = "Factor validation complete.";
if (anyRejected) {
// At least one factor is rejected → stay in factor flow and ask user to re-upload
newStatus = ReqClaimStatus.FactorRejected;
responseMessage =
"Factors reviewed. Some factors were rejected, awaiting user resubmission.";
// TODO: Send an SMS or notification to the user here.
} else if (anyStillPending) {
// Some factors still pending (e.g. not validated in this batch)
responseMessage =
"Some factors were approved, but others are still pending validation.";
} else {
// All required factors are approved:
// Move claim into a state where the user can review the final decision and object if needed.
newStatus = ReqClaimStatus.CheckedRequest;
responseMessage =
"All required factors have been approved. The claim is now ready for user review and possible objection.";
}
if (newStatus) {
const update: any = {
$set: { claimStatus: newStatus },
};
// When factors are fully approved, also move the step to WaitingForUserToReact,
// just like the non-factor flow in submitReplyRequest.
if (newStatus === ReqClaimStatus.CheckedRequest) {
update.$set.currentStep = ClaimStepsEnum.WaitingForUserToReact;
update.$set.nextStep = ClaimStepsEnum.WaitingForUserToReact;
update.$push = {
steps: ClaimStepsEnum.WaitingForUserToReact,
};
}
await this.claimRequestManagementDbService.findByIdAndUpdate(
claimId,
update,
);
}
return {
message: responseMessage,
newStatus: newStatus || updatedClaim.claimStatus,
};
}
/**
* Close the claim after factor validation without a second insurer-review signature.
* Owner already uploaded factor files; mixed replies already have priced-line acceptance on file.
*/
private async completeClaimCaseAfterFactorValidationV2(
claimRequestId: string,
claimForTenant: any,
actor: { sub: string; fullName?: string; clientKey?: string },
historyType: string,
historyActor: {
actorId: Types.ObjectId;
actorName?: string;
actorType: string;
},
metadata: Record<string, unknown>,
): Promise<void> {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
history: {
type: historyType,
actor: historyActor,
timestamp: new Date(),
metadata,
},
},
});
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claimForTenant, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:factor_validation:${historyType}:${actor.sub}`,
});
}
/**
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` (53_000_000; same as initial expert reply).
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/
async validateClaimFactorsV2(
claimRequestId: string,
body: FactorValidationV2Dto,
actor: { sub: string; fullName?: string; clientKey?: string },
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
if (!claimIsAwaitingExpertFactorValidationV2(claim)) {
throw new BadRequestException(
"Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
);
}
const replyField = claim.evaluation?.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
const finalReply =
replyField === "damageExpertReplyFinal"
? claim.evaluation?.damageExpertReplyFinal
: claim.evaluation?.damageExpertReply;
if (!finalReply?.parts?.length) {
throw new BadRequestException("No expert reply found in this claim.");
}
const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded);
if (requiredFactors.length === 0) {
throw new BadRequestException("No parts require factor validation.");
}
if (!requiredFactors.every((p) => !!p.factorLink)) {
throw new BadRequestException(
"Not all required factors have been uploaded yet.",
);
}
const $set: Record<string, unknown> = {};
for (const decision of body.decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => String(p.partId) === String(decision.partId),
);
if (partIndex === -1) {
this.logger.warn(
`Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`,
);
continue;
}
const part = finalReply.parts[partIndex];
if (!part.factorNeeded) {
this.logger.warn(
`Part ${decision.partId} is not factor-needed, skipping.`,
);
continue;
}
const base = `evaluation.${replyField}.parts.${partIndex}`;
$set[`${base}.factorStatus`] = decision.status;
$set[`${base}.rejectionReason`] = decision.rejectionReason ?? null;
if (decision.price !== undefined) {
$set[`${base}.price`] = decision.price;
}
if (decision.salary !== undefined) {
$set[`${base}.salary`] = decision.salary;
}
if (decision.totalPayment !== undefined) {
$set[`${base}.totalPayment`] = decision.totalPayment;
}
if (
decision.status === FactorStatus.APPROVED ||
decision.status === FactorStatus.REJECTED
) {
if (!this.expertFactorValidationDecisionHasLinePricing(decision)) {
throw new BadRequestException(
`Part ${decision.partId}: ${decision.status === FactorStatus.APPROVED ? "approved" : "rejected"} factor lines require expert totalPayment (or both price and salary).`,
);
}
}
}
if (Object.keys($set).length === 0) {
throw new BadRequestException("No valid factor decisions to apply.");
}
if (factorValidationSnapshot) {
$set["evaluation.factorValidationExpertProfileSnapshot"] =
factorValidationSnapshot;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
});
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`,
});
const updatedClaim = await this.claimCaseDbService.findById(claimRequestId);
const updatedReply =
replyField === "damageExpertReplyFinal"
? updatedClaim!.evaluation!.damageExpertReplyFinal!
: updatedClaim!.evaluation!.damageExpertReply!;
const reqParts = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = reqParts.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = reqParts.some(
(p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING,
);
const baseMessage = "Factor validation updated.";
if (anyStillPending) {
return {
message: `${baseMessage} Some factors are still pending review.`,
claimRequestId,
claimStatus: updatedClaim!.claimStatus,
caseStatus: updatedClaim!.status,
pendingFactorValidation: true,
};
}
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
const line = this.claimReplyPartLineTotalForCap(part);
if (isNaN(line)) {
throw new BadRequestException({
message: `Part ${String((part as { partId?: string }).partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
error: "PRICE_CAP_ERROR",
});
}
totalPrice += line;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap: PRICE_CAP,
});
}
const historyActor = {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
};
if (anyRejected) {
await this.completeClaimCaseAfterFactorValidationV2(
claimRequestId,
claim,
actor,
"FACTORS_REJECTED_REPRICED_AUTO_COMPLETED",
historyActor,
{ replyField },
);
return {
message:
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
};
}
await this.completeClaimCaseAfterFactorValidationV2(
claimRequestId,
claim,
actor,
"FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED",
historyActor,
{ replyField },
);
return {
message:
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_AUTO_COMPLETED",
};
}
async inPersonVisit(requestId: string, actorDetail: any) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Blame not found");
}
const visitSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
const updated = await this.claimRequestManagementDbService.findAndUpdate(
requestId,
{
claimStatus: ReqClaimStatus.InPersonVisit,
...(visitSnapshot && {
damageExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
},
);
await this.recordClaimExpertActivity({
expertId: String(actorDetail.sub),
tenantId: String(request.userClientKey ?? actorDetail.clientKey ?? ""),
claimId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${requestId}:handled:${actorDetail.sub}`,
});
return updated;
}
async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId);
}
private globalErrHandler(error) {
this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus);
}
/**
* V2: Lock a claim request for expert review.
*
* Validations:
* - Claim must exist
* - Status must be either:
* • `WAITING_FOR_DAMAGE_EXPERT` (initial damage review queue), OR
* • the factor-validation queue (`EXPERT_VALIDATING_REPAIR_FACTORS`,
* or legacy `WAITING_FOR_INSURER_APPROVAL`, with
* `claimStatus=UNDER_REVIEW` + `workflow.currentStep=EXPERT_COST_EVALUATION`).
* - Must not already be locked by another expert.
*
* On success:
* - Always sets `workflow.locked=true`, `workflow.lockedBy=actor`,
* `workflow.lockedAt/expiredAt`, and snapshots pre-lock queue state.
* - For the **damage review** path: status → `EXPERT_REVIEWING`,
* `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.
* - For the **factor-validation** path: leaves `status`, `claimStatus`, and
* `workflow.currentStep` untouched so the claim stays in the factor-validation
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
* treating it correctly when the lock expires).
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
if (!isDamageReviewLock && !isFactorValidationLock) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const lockAt = new Date();
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
// Common lock fields written in both branches.
const baseLockUpdate: Record<string, unknown> = {
'workflow.locked': true,
'workflow.lockedAt': lockAt,
'workflow.expiredAt': expiredAt,
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
'workflow.preLockQueueSnapshot': {
claimStatus: claim.claimStatus,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
),
...(claim.workflow?.nextStep != null
? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}),
},
$push: {
history: {
type: "CLAIM_LOCKED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
note: isFactorValidationLock
? `Claim locked for factor validation by damage expert ${actor.fullName}`
: `Claim locked by damage expert ${actor.fullName}`,
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
},
},
},
};
// Damage-review lock advances status/step; factor-validation lock keeps them
// intact so the claim remains in the factor-validation queue when the lock expires.
const update: Record<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, update);
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
});
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
if (ownerPhone) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: ownerPhone,
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
},
);
}
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
async submitResendDocsV2(
claimRequestId: string,
reply: ClaimSubmitResendV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a resend request',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
// Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend).
// After a successful submit, status becomes WAITING_FOR_USER_RESEND so this endpoint is not callable until the owner finishes.
const existingResend = claim.evaluation?.damageExpertResend;
if (
existingResend &&
!existingResend.fulfilledAt &&
resendRequestHasPayload(existingResend)
) {
throw new ConflictException(
"A resend request is still open for this claim. Wait until the owner completes uploads or contact support if the case is stuck.",
);
}
if (existingResend?.fulfilledAt) {
throw new BadRequestException(
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
);
}
const allowedDocTypes = new Set<string>(
Object.values(ClaimRequiredDocumentType),
);
const rawDocs = reply.resendDocuments ?? [];
const docs: ClaimRequiredDocumentType[] = [];
for (const x of rawDocs) {
const s = String(x).trim();
if (!allowedDocTypes.has(s)) {
throw new BadRequestException(
`Invalid resendDocuments value: "${String(x)}". Must be a ClaimRequiredDocumentType string (e.g. national_card, damaged_driving_license_front).`,
);
}
docs.push(s as ClaimRequiredDocumentType);
}
const uniqueDocs = [...new Set(docs)];
const normalizedParts = (reply.resendCarParts ?? []).map((p) => {
const row: Record<string, unknown> = {
key: p.key,
label_fa: p.label_fa,
label_en: p.label_en,
captured: false,
};
const id = (p as { id?: number }).id;
if (typeof id === "number" && Number.isFinite(id)) {
row.id = Math.trunc(id);
}
return row;
});
const desc = String(reply.resendDescription ?? "").trim();
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
throw new BadRequestException(
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
);
}
const resendSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
claimStatus: ClaimStatus.NEEDS_REVISION,
"workflow.locked": false,
"workflow.currentStep": ClaimWorkflowStep.USER_EXPERT_RESEND,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"evaluation.damageExpertResend": {
resendDescription: desc || undefined,
resendDocuments: uniqueDocs,
resendCarParts: normalizedParts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
},
$push: {
history: {
type: "EXPERT_RESEND_REQUESTED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
documentCount: uniqueDocs.length,
carPartCount: normalizedParts.length,
},
},
},
});
const ownerPhoneResend = await this.resolveClaimOwnerPhone(claim);
if (ownerPhoneResend) {
await this.smsOrchestrationService.sendResendDocumentsNotice({
receptor: ownerPhoneResend,
fileKind: "claim",
publicId: claim.publicId,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
});
}
return {
claimRequestId,
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
claimStatus: ClaimStatus.NEEDS_REVISION,
currentStep: ClaimWorkflowStep.USER_EXPERT_RESEND,
nextStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
message:
"Resend request recorded. The owner must upload the requested documents and/or part photos (or confirm if only instructions were given). The claim will return to the damage expert queue when complete.",
};
}
/**
* V2: Submit damage expert reply for a claim.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
* - Each part must include `daghi` (option + conditional price/branchId) like V1
*
* On success:
* - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`)
* - Unlocks the workflow
* - Pricing-only (`factorNeeded=false` everywhere): INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW → owner final sign/reject.
* - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors).
* - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign.
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
actor: any,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in a reviewable state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a reply',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
// Price cap validation
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
error: 'PRICE_CAP_ERROR',
totalPrice,
priceCap: PRICE_CAP,
});
}
const carTypeSubmit = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const daghiNormalized =
reply.parts?.length > 0
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
: [];
const ownerSelectedParts = normalizeDamageSelectedParts(
(claim as any)?.damage?.selectedParts,
carTypeSubmit,
(claim as any)?.damage?.selectedOuterParts,
);
const ownerSelectedIdentitySet = new Set(
ownerSelectedParts.map((p) => partIdentityKey(p)),
);
const seenIdentities = new Set<string>();
const processedParts = daghiNormalized.map((p) => {
let carPartDamage: Record<string, unknown>;
try {
carPartDamage = normalizeCarPartDamageForExpertReply(
p.carPartDamage as unknown,
carTypeSubmit,
);
} catch (err) {
throw new BadRequestException(
`Invalid carPartDamage for part ${p.partId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage);
if (!identityKey) {
throw new BadRequestException(
`Invalid carPartDamage for part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`,
);
}
if (
ownerSelectedIdentitySet.size > 0 &&
!ownerSelectedIdentitySet.has(identityKey)
) {
throw new BadRequestException(
`Part "${identityKey}" is not in the owner's selected damaged parts; only parts the user picked (or added via objection) can be priced.`,
);
}
if (seenIdentities.has(identityKey)) {
throw new BadRequestException(
`Duplicate part submitted in expert reply: ${identityKey}.`,
);
}
seenIdentities.add(identityKey);
return { ...p, partId: identityKey, carPartDamage };
});
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
processedParts,
);
const needsFactorUpload =
reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
if (objectionSubmitted && hasFinalReply) {
throw new ConflictException(
'A final expert reply after objection already exists for this claim.',
);
}
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
const replyField = isFinalReplyAfterObjection
? 'damageExpertReplyFinal'
: 'damageExpertReply';
const completedStep = isFinalReplyAfterObjection
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const expertProfileSnapshot = await this.snapshotDamageExpert(actor.sub);
const replyPayload = {
description: reply.description,
parts: processedParts,
submittedAt: new Date(),
actorDetail: {
actorId: actor.sub,
actorName: actor.fullName,
},
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
let currentStep = ClaimWorkflowStep.INSURER_REVIEW;
let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts);
let nextClaimStatus = ClaimStatus.APPROVED;
if (needsFactorUpload) {
nextClaimStatus = ClaimStatus.NEEDS_REVISION;
if (mixedFactorAndPrice) {
currentStep = ClaimWorkflowStep.INSURER_REVIEW;
nextWorkflowStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
} else {
currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS;
nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION;
}
}
const updatePayload: Record<string, unknown> = {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
'workflow.locked': false,
$unset: {
'workflow.lockedAt': '',
'workflow.expiredAt': '',
'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
'evaluation.ownerInsurerApproval': "",
'evaluation.ownerPricedPartsApproval': "",
},
'workflow.currentStep': currentStep,
'workflow.nextStep': needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
[`evaluation.${replyField}`]: replyPayload,
$push: {
'workflow.completedSteps': completedStep,
history: {
type: isFinalReplyAfterObjection
? 'EXPERT_FINAL_REPLY_SUBMITTED'
: 'EXPERT_REPLY_SUBMITTED',
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: 'damage_expert',
},
timestamp: new Date(),
metadata: {
factorNeeded: needsFactorUpload,
mixedFactorAndPrice,
allFactorLines,
partsCount: processedParts.length,
isFinalReplyAfterObjection,
replyField,
},
},
},
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
});
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
if (ownerPhoneNotify && !needsFactorUpload) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: ownerPhoneNotify,
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
});
}
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep,
workflowNextStep: needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
factorNeeded: needsFactorUpload,
mixedPricingAndFactors: mixedFactorAndPrice,
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
isFinalReplyAfterObjection,
};
}
/**
* V2: Expert marks claim for in-person visit.
*
* This is used when the expert decides the user must attend in person
* before a final assessment can be made.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert
* - Status must be EXPERT_REVIEWING
*
* On success:
* - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION
* - Sets evaluation.visitLocation (optional note)
* - Records history event IN_PERSON_VISIT_REQUESTED
* - Unlocks the workflow so user can act
*/
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before requesting an in-person visit',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
const visitSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
$unset: {
'workflow.lockedAt': '',
'workflow.expiredAt': '',
'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
},
...(note ? { 'evaluation.visitLocation': note } : {}),
...(visitSnapshot && {
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
}),
$push: {
history: {
type: "IN_PERSON_VISIT_REQUESTED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
note: note || "Expert requested in-person visit",
},
},
},
});
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`,
});
return {
claimRequestId,
status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: "In-person visit requested. User will be notified.",
};
}
/**
* V2: Count claim cases for this damage experts tenant, grouped for dashboard:
* `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES);
* other {@link ClaimCaseStatus} keys unchanged.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
const clientKey = requireActorClientKey(actor);
const rows = await this.claimCaseDbService.find({}, { lean: true });
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
if (!claimCaseTouchesClient(doc, clientKey)) continue;
const st = String(doc.status ?? "");
const key = claimCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
return buckets;
}
/**
* V2: Get claim list for damage expert
*
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
* 2. Locked by this expert (in-progress)
* 3. EXPERT_VALIDATING_REPAIR_FACTORS (or legacy WAITING_FOR_INSURER_APPROVAL) + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
*/
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
requireActorClientKey(actor);
const actorId = actor.sub;
const clientKey = actor.clientKey as string;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
const claims = await this.claimCaseDbService.find({
$or: [
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
},
// Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
},
// User uploaded all factors; expert must approve/reject (unlocked queue)
{
$or: [
{
status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
{
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
],
},
],
});
const filtered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
const staleLockToReconcile = filtered.filter(
(c) =>
c.workflow?.locked &&
!this.isClaimV2WorkflowLockCurrentlyEnforced(c),
);
const touchedIds = (
await Promise.all(
staleLockToReconcile.map(async (c) => {
const updated = await this.expireClaimWorkflowLockV2IfStale(
String(c._id),
);
return updated ? String(c._id) : null;
}),
)
).filter((id): id is string => id != null);
if (touchedIds.length > 0) {
const refreshed = await this.claimCaseDbService.find({
_id: { $in: touchedIds.map((id) => new Types.ObjectId(id)) },
});
const byId = new Map(
refreshed.map((r) => [String(r._id), r] as const),
);
for (const c of filtered) {
const r = byId.get(String(c._id));
if (r) Object.assign(c, r);
}
}
const blameIds = [
...new Set(
filtered
.map((c) => c.blameRequestId?.toString())
.filter((id): id is string => !!id),
),
];
const blames =
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
const list = filtered.map((c) => {
const awaitingFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive =
!!(c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c));
const statusForList =
c.status === ClaimCaseStatus.EXPERT_REVIEWING
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
: c.status;
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: statusForList,
currentStep: c.workflow?.currentStep || "",
locked: lockActive,
lockedBy:
lockActive && c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
}
: undefined,
vehicle: v
? {
carName: v.carName,
carModel: v.carModel,
carType: v.carType,
}
: undefined,
...fileCtx,
createdAt: c.createdAt,
awaitingFactorValidation,
};
}) as ClaimListItemV2Dto[];
return { list, total: list.length };
}
/**
* Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`:
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
*/
private async buildBlameCaseSnapshotForClaimDetail(
blameRequestId: string,
): Promise<Record<string, unknown> | null> {
const doc = await this.blameRequestDbService.findByIdWithoutHistory(
blameRequestId,
);
if (!doc) {
return null;
}
const docRec = doc as Record<string, unknown>;
const built = buildMutualAgreementExpertDecision(docRec);
const existingDecision = (docRec.expert as Record<string, unknown> | undefined)
?.decision as Record<string, unknown> | undefined;
if (built && !existingDecision?.guiltyPartyId) {
const prevExpert =
typeof docRec.expert === "object" && docRec.expert !== null
? { ...(docRec.expert as Record<string, unknown>) }
: {};
const expert = { ...prevExpert, decision: built };
await this.blameRequestDbService.findByIdAndUpdate(blameRequestId, {
$set: { expert },
});
docRec.expert = expert;
}
const parties = (doc.parties ?? []) as Array<{
evidence?: {
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
}>;
for (const party of parties) {
if (!party.evidence) continue;
const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) {
evidence.videoUrl = buildFileLink(videoDoc.path);
}
}
if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) {
voiceUrls.push(buildFileLink(voiceDoc.path));
}
}
evidence.voiceUrls = voiceUrls;
}
}
const createdAt = doc.createdAt
? new Date(doc.createdAt as string | number)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number)
: new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
(doc as Record<string, unknown>).createdAtFormatted =
`${createdDate} ${createdTime}`;
(doc as Record<string, unknown>).updatedAtFormatted =
`${updatedDate} ${updatedTime}`;
docRec.parties = enrichBlamePartiesForAgreementView(docRec);
return docRec;
}
/** Strip bulky insurer-inquiry payloads (`raw` / `mapped`) from vehicle.inquiry. */
private sanitizeVehicleInquiryForApi(vehicle: any) {
if (!vehicle || typeof vehicle !== "object") return vehicle;
const inquiry = vehicle.inquiry;
if (!inquiry || typeof inquiry !== "object") return vehicle;
const { raw, mapped, ...safeInquiry } = inquiry as Record<string, unknown>;
return {
...vehicle,
inquiry: safeInquiry,
};
}
/** Linked blame snapshot for damage-expert detail: keep API response lean. */
private sanitizeBlameCaseForClaimDetailApi(
blame: Record<string, unknown>,
): Record<string, unknown> {
const out = { ...blame };
const parties = out.parties;
if (Array.isArray(parties)) {
out.parties = parties.map((p: any) =>
p && typeof p === "object"
? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) }
: p,
);
}
return out;
}
/** JSON-safe `expert.decision` for API responses (ObjectIds → string). */
private serializeBlameExpertDecisionForClaimDetail(
decision: unknown,
): Record<string, unknown> | undefined {
if (!decision || typeof decision !== "object") {
return undefined;
}
const d = decision as Record<string, unknown>;
const guilty = d.guiltyPartyId;
const decidedBy = d.decidedByExpertId;
const decidedAt = d.decidedAt;
return {
...d,
guiltyPartyId:
guilty != null && typeof (guilty as { toString?: () => string }).toString === "function"
? String(guilty)
: guilty,
decidedByExpertId:
decidedBy != null &&
typeof (decidedBy as { toString?: () => string }).toString === "function"
? String(decidedBy)
: decidedBy,
decidedAt:
decidedAt instanceof Date
? decidedAt.toISOString()
: decidedAt,
};
}
private claimHasDamageExpertReply(claim: {
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
}): boolean {
return !!(
claim.evaluation?.damageExpertReply ||
claim.evaluation?.damageExpertReplyFinal
);
}
/**
* True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL).
* Missing both timestamps on old documents: treat lock as enforced until cleared.
*/
private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean {
if (!claim?.workflow?.locked) return false;
const exp = claim.workflow?.expiredAt;
if (exp) {
return Date.now() < new Date(exp as Date).getTime();
}
const la = claim.workflow?.lockedAt as Date | string | undefined;
if (!la) return true;
return (
Date.now() <
new Date(la).getTime() + this.claimV2WorkflowLockTtlMs
);
}
/**
* EXPERT_REVIEWING + expired lock is not listed until status is reverted. Run expire for this tenant
* so the next query returns those cases as WAITING_FOR_DAMAGE_EXPERT.
*/
private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: {
sub: string;
clientKey?: string;
}): Promise<void> {
const clientKey = requireActorClientKey(actor);
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
const now = new Date();
const candidates = (await this.claimCaseDbService.find(
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
"workflow.locked": true,
$or: [
{ "workflow.expiredAt": { $lte: now } },
{
$expr: {
$and: [
{ $eq: ["$workflow.locked", true] },
{
$lte: [
{ $add: ["$workflow.lockedAt", lockTtlMs] },
now,
],
},
],
},
},
],
},
{ lean: true, select: "_id owner" },
)) as Array<{ _id: unknown; owner?: unknown }>;
const relevant = candidates.filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
await Promise.all(
relevant.map((c) =>
this.expireClaimWorkflowLockV2IfStale(String(c._id)),
),
);
}
/**
* Persist unlock when claim V2 workflow lock TTL has passed.
* When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from
* `workflow.preLockQueueSnapshot` (or defaults) and sets status back to WAITING_FOR_DAMAGE_EXPERT
* so the claim reappears in the open expert queue (same intent as blame list expiry + in-memory unlock).
*/
private async expireClaimWorkflowLockV2IfStale(
claimRequestId: string,
): Promise<boolean> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim?.workflow?.locked) return false;
const expiredAt = claim.workflow?.expiredAt;
const lockedAt = claim.workflow?.lockedAt;
const now = Date.now();
const ttl = this.claimV2WorkflowLockTtlMs;
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
if (
!expiredAt &&
lockedAt &&
now < new Date(lockedAt as Date).getTime() + ttl
) {
return false;
}
const lockedById = claim.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId),
"workflow.locked": true,
};
if (expiredAt) {
filter["workflow.expiredAt"] = expiredAt;
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(String(lockedById));
}
const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const snap = claim.workflow?.preLockQueueSnapshot as
| {
claimStatus?: ClaimStatus;
currentStep?: ClaimWorkflowStep;
nextStep?: ClaimWorkflowStep;
}
| undefined;
const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING;
const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot(
snap?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
);
const restoreNext =
snap?.nextStep != null
? this.normalizeClaimWorkflowStepForSnapshot(
snap.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
)
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const lockFieldsUnset = {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
};
const update = needsQueueRestore
? {
$set: {
"workflow.locked": false,
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: restoreClaimStatus,
"workflow.currentStep": restoreCurrent,
"workflow.nextStep": restoreNext,
},
$unset: lockFieldsUnset,
}
: {
$set: { "workflow.locked": false },
$unset: lockFieldsUnset,
};
const cleared = await this.claimCaseDbService.findOneAndUpdate(
filter as any,
update as any,
{ new: false },
);
if (cleared && lockedById && claim.owner?.userClientKey) {
await this.recordClaimExpertActivity({
expertId: String(lockedById),
tenantId: String(claim.owner.userClientKey),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.UNCHECKED,
idempotencyKey: `claim:${claimRequestId}:unchecked:expired:${lockedById}`,
});
}
return !!cleared;
}
/**
* V2: Get claim detail for damage expert
*
* Validations:
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
* or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
* - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
*/
async getClaimDetailV2(
claimRequestId: string,
actor: any,
): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
assertClaimCaseForTenant(claim, actor);
const isDamageExpertPhase =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim);
if (!isDamageExpertPhase && !isFactorValidationPending) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockBlocksOthers) {
const lockerId = claim.workflow?.lockedBy?.actorId?.toString();
if (lockerId && lockerId !== actorId) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
if (requiredDocs) {
const keys =
requiredDocs instanceof Map
? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs);
for (const k of keys as string[]) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
};
}
}
// Build car angles map
const carAnglesData = claim.media?.carAngles as any;
const damagedPartsDataForAngles = claim.media?.damagedParts as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = getClaimCarAngleCaptureBlob(
carAnglesData,
damagedPartsDataForAngles,
k as ClaimCarAngleKey,
) as { url?: string; path?: string } | undefined;
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// Build damaged parts list (aligned with normalized selected parts)
const carTypeExpert = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNormExpert = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carTypeExpert,
(claim.damage as any)?.selectedOuterParts,
);
const damagedPartsDataExpert = claim.media?.damagedParts as any;
const damagedParts = selectedNormExpert.map((sp, index) => {
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
const cap = getDamagedPartCaptureBlob(
damagedPartsDataExpert,
ck,
selectedNormExpert,
) as { url?: string; path?: string } | undefined;
return {
index,
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
captured: hasDamagedPartCapture(
damagedPartsDataExpert,
ck,
selectedNormExpert,
),
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
});
// --- Combine both branches of the conflict, preserving necessary logic from both ---
// 1. Vehicle payload logic from "upstream"
let vehiclePayload = claim.vehicle as any;
const hasClaimVehicle =
vehiclePayload &&
(vehiclePayload.carName ||
vehiclePayload.carModel ||
vehiclePayload.carType ||
vehiclePayload.plate);
let blameLean: any = null;
// 2. Video capture and blame case db queries from "stashed"
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
const blameRequestIdStr = claim.blameRequestId?.toString();
const [
videoCaptureRow,
blameCase,
blameLeanRows
] = await Promise.all([
videoCaptureIdRaw
? this.claimVideoCaptureDbService.findById(String(videoCaptureIdRaw))
: Promise.resolve(null),
blameRequestIdStr
? this.buildBlameCaseSnapshotForClaimDetail(blameRequestIdStr)
: Promise.resolve(null),
claim.blameRequestId
? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{ lean: true, select: "type parties expert.decision" },
)
: Promise.resolve([]),
]);
if (Array.isArray(blameLeanRows) && blameLeanRows.length) {
blameLean = blameLeanRows[0] ?? null;
}
// If claim vehicle not found and blameLean exists, replace vehiclePayload
if (!hasClaimVehicle && blameLean) {
const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim);
if (fromBlame) vehiclePayload = fromBlame;
}
// 3. Blame file context logic from "upstream"
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
// 4. videoCapture logic from "stashed"
let videoCapture: ClaimDetailV2ResponseDto['videoCapture'] = undefined;
if (videoCaptureRow) {
const vc = videoCaptureRow as any;
videoCapture = {
id: String(vc._id ?? videoCaptureIdRaw),
fileName: vc.fileName,
path: vc.path,
url: vc.path ? buildFileLink(vc.path) : undefined,
};
}
const blameExpertDecision = blameCase
? this.serializeBlameExpertDecisionForClaimDetail(
(blameCase.expert as Record<string, unknown> | undefined)?.decision,
)
: undefined;
const money = (claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } })
.money;
const moneyPayload =
money && (money.sheba != null || money.nationalCodeOfInsurer != null)
? {
sheba: money.sheba,
nationalCodeOfInsurer: money.nationalCodeOfInsurer,
}
: undefined;
const statusForExpertApi =
claim.status === ClaimCaseStatus.EXPERT_REVIEWING
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
: claim.status;
const objection = (claim as any).evaluation?.objection
? {
objectionParts:
(claim as any).evaluation.objection.objectionParts ?? [],
newParts: (claim as any).evaluation.objection.newParts ?? [],
submittedAt: (claim as any).evaluation.objection.submittedAt,
}
: undefined;
const blameCaseForApi = blameCase
? this.sanitizeBlameCaseForClaimDetailApi(
blameCase as Record<string, unknown>,
)
: undefined;
// Enrich the full evaluation block with `signLink`s for every place a user
// signature is referenced, then assemble the response slice.
//
// We always expose owner approval blocks (when present in the DB) because
// the signature URL is needed across all phases of the claim. The expert
// reply payloads are still gated on `isFactorValidationPending` to match
// the historical contract of this endpoint.
const claimEvaluationRaw = (claim as any).evaluation as
| Record<string, unknown>
| undefined;
const enrichedEvaluation = await this.enrichClaimEvaluationForExpert(
claimEvaluationRaw,
);
let evaluationForApi: Record<string, unknown> | undefined;
if (enrichedEvaluation) {
evaluationForApi = {};
if (isFactorValidationPending) {
if (enrichedEvaluation.damageExpertReply !== undefined) {
evaluationForApi.damageExpertReply =
enrichedEvaluation.damageExpertReply;
}
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
evaluationForApi.damageExpertReplyFinal =
enrichedEvaluation.damageExpertReplyFinal;
}
}
if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
evaluationForApi.ownerInsurerApproval =
enrichedEvaluation.ownerInsurerApproval;
}
if (enrichedEvaluation.ownerPricedPartsApproval !== undefined) {
evaluationForApi.ownerPricedPartsApproval =
enrichedEvaluation.ownerPricedPartsApproval;
}
if (Object.keys(evaluationForApi).length === 0) {
evaluationForApi = undefined;
}
}
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
status: statusForExpertApi,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy
? {
actorId: claim.workflow.lockedBy.actorId?.toString(),
actorName: claim.workflow.lockedBy.actorName,
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
expiredAt: (claim.workflow as any).expiredAt?.toISOString(),
}
: undefined,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: vehiclePayload
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
selectedParts: selectedNormExpert,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
awaitingFactorValidation: isFactorValidationPending,
evaluation: evaluationForApi as
| ClaimDetailV2ResponseDto["evaluation"]
| undefined,
objection,
videoCapture,
blameCase: blameCaseForApi,
blameExpertDecision,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
/**
* V2: Damage expert can modify selected damaged parts before final submit.
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
*/
async updateClaimDamagedPartsV2(
claimRequestId: string,
body: UpdateClaimDamagedPartsV2Dto,
actor: any,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before modifying damaged parts",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException("This claim is locked by another expert");
}
const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub);
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const previousNorm = normalizeDamageSelectedParts(
(claim as any).damage?.selectedParts,
carType,
(claim as any).damage?.selectedOuterParts,
);
const prevMedia = coerceDamagedPartsMediaToArray(
(claim as any).media?.damagedParts,
previousNorm,
);
const nextNorm = body.selectedParts.map((p) => ({
id: p.id ?? null,
name: String(p.name || "").trim(),
side: String(p.side || ""),
label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(),
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
}));
const matchPreviousIndex = (
next: (typeof nextNorm)[0],
previous: typeof previousNorm,
): number => {
if (next.id != null) {
const byId = previous.findIndex((x) => x.id === next.id);
if (byId >= 0) return byId;
}
if (next.catalogKey) {
const byCk = previous.findIndex((x) => x.catalogKey === next.catalogKey);
if (byCk >= 0) return byCk;
}
return previous.findIndex(
(x) => x.name === next.name && x.side === next.side,
);
};
const nextMedia = nextNorm.map((sp) => {
const j = matchPreviousIndex(sp, previousNorm);
const row =
j >= 0 && prevMedia[j] && typeof prevMedia[j] === "object"
? (prevMedia[j] as Record<string, unknown>)
: {};
return {
...(sp.id != null ? { id: sp.id } : {}),
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}),
...(row.path ? { path: row.path } : {}),
...(row.fileName ? { fileName: row.fileName } : {}),
...(row.url ? { url: row.url } : {}),
...(row.capturedAt ? { capturedAt: row.capturedAt } : {}),
};
});
const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts]
: [];
const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
previousSelectedParts: previous,
selectedParts: nextNorm,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
},
});
return {
claimRequestId: claim._id.toString(),
selectedParts: nextNorm,
previousSelectedParts: previous,
message: "Damaged parts updated successfully.",
};
}
}