1
0
forked from Yara724/api
Files
yara724-api/src/expert-claim/expert-claim.service.ts
2026-07-05 11:46:37 +03:30

5326 lines
177 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 {
ClaimRequestManagementService,
FanavaranAutoSubmitResult,
FanavaranExpertiseSubmitResult,
} from "src/claim-request-management/claim-request-management.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, resolveStoredFileUrl } 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 {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
claimWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import {
EXPERT_WORKFLOW_LOCK_TTL_MS,
expertWorkflowLockExpiredAt,
} from "src/helpers/expert-workflow-lock";
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 { RoleEnum } from "src/Types&Enums/role.enum";
import {
assertClaimCaseForTenant,
assertClaimCaseForExpertActor,
blameCaseTouchesClient,
claimCaseInitiatedByFieldExpert,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
claimCaseStatusToReportBucket,
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import {
claimDocInExpertPortfolio,
expertPortfolioFileIdsFromActivityEvents,
objectIdsFromStringSet,
} from "src/helpers/expert-portfolio";
import {
buildCoefficientsFromPartSeverities,
buildDamagedPartsPriceDropLines,
buildPriceDropCatalogForApi,
calculateClaimPriceDrop,
PRICE_DROP_SEVERITY_OPTIONS,
resolveVehicleModelYearFromBlame,
} from "src/helpers/claim-price-drop";
import { UpsertClaimPriceDropV2Dto } from "./dto/claim-price-drop-v2.dto";
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 {
normalizeResendDocumentKeys,
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,
catalogPartIdFromCarPartDamage,
catalogPartIdFromSelectedPart,
partLookupKey,
resolvePartForExpertReply,
sameCatalogPartId,
sanitizeDamageSelectedPartV2,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
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 (Toman; priced parts + factor lines after validation). */
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import {
applyListQueryV2,
isInListDateRange,
parseListDateRange,
} from "src/helpers/list-query-v2";
import {
countUnifiedFileStatuses,
getUnifiedFileStatusCatalog,
resolveUnifiedFileStatus,
} from "src/helpers/unified-file-status";
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
@Injectable()
export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name);
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,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
private appendFanavaranAutoSubmitToMessage(
baseMessage: string,
fanavaran: FanavaranAutoSubmitResult | FanavaranExpertiseSubmitResult,
): string {
if (fanavaran.submitted) {
return `${baseMessage} The claim was sent to Fanavaran successfully.`;
}
if (fanavaran.warning) {
return `${baseMessage} ${fanavaran.warning}`;
}
return baseMessage;
}
/**
* 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 linked blame when resolving field-expert ownership on a claim. */
private async loadBlameForClaim(
claim: { blameRequestId?: unknown } | null | undefined,
) {
const id = claim?.blameRequestId?.toString?.();
if (!id) return null;
return this.blameRequestDbService.findById(id);
}
private async assertExpertActorOnClaim(
claim: any,
actor: any,
): Promise<void> {
const blame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, blame);
}
private async fieldExpertOwnsClaim(
claim: any,
actor: { sub: string },
): Promise<boolean> {
const blame = await this.loadBlameForClaim(claim);
return claimCaseInitiatedByFieldExpert(claim, actor, blame);
}
/** 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;
}
/** Damaged-party mobile for claim SMS: prefer `damagedPartyUserId`, else `owner.userId`. */
private async resolveClaimOwnerPhone(
claim: any,
): Promise<string | undefined> {
const notifyUserId = claim?.damagedPartyUserId ?? claim?.owner?.userId;
if (!notifyUserId) return undefined;
const ownerUserId = String(notifyUserId);
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 lockExpiresAt = expertWorkflowLockExpiredAt();
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
await this.claimRequestManagementDbService.findOneAndUpdate(
new Types.ObjectId(requestId),
{
lockFile: true,
claimStatus: ReqClaimStatus.ReviewRequest,
unlockTime: lockExpiresAt,
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, lockExpiresAt.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?: number | 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), when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
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 > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
priceCap,
});
}
}
// 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).
* When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
* 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");
}
await this.assertExpertActorOnClaim(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) =>
sameCatalogPartId(p.partId, 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 priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
const line = this.claimReplyPartLineTotalForCap(part);
if (isNaN(line)) {
throw new BadRequestException({
message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
error: "PRICE_CAP_ERROR",
});
}
totalPrice += line;
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
});
}
}
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 },
);
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
const fanavaranExpertise =
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
claimRequestId,
);
return {
message: this.appendFanavaranAutoSubmitToMessage(
this.appendFanavaranAutoSubmitToMessage(
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
fanavaran,
),
fanavaranExpertise,
),
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
fanavaran,
fanavaranExpertise,
};
}
await this.completeClaimCaseAfterFactorValidationV2(
claimRequestId,
claim,
actor,
"FACTORS_VALIDATED_ALL_APPROVED_AUTO_COMPLETED",
historyActor,
{ replyField },
);
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
const fanavaranExpertise =
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
claimRequestId,
);
return {
message: this.appendFanavaranAutoSubmitToMessage(
this.appendFanavaranAutoSubmitToMessage(
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
fanavaran,
),
fanavaranExpertise,
),
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
outcome: "ALL_APPROVED_AUTO_COMPLETED",
fanavaran,
fanavaranExpertise,
};
}
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);
}
/**
* V2: branches for the damage expert's insurer tenant (JWT `clientKey`).
* Same data as v1 `GET expert-claim/branches/:insuranceId` without passing client id.
*/
async retrieveInsuranceBranchesV2(actor: { clientKey?: string }) {
const clientKey = requireActorClientKey(actor);
return await this.branchDbService.findAll(clientKey);
}
private globalErrHandler(error) {
this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus);
}
/**
* FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame
* file. Operates on the blame document (not the claim workflow lock).
*
* Rules:
* - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated.
* - Status must be WAITING_FOR_FILE_REVIEWER.
* - No other reviewer may have taken it (assignedFileReviewerId absent/null).
* - Idempotent: same reviewer calling again gets "already_assigned_to_you".
*/
private async assignFileReviewerToV4Blame(
claimRequestId: string,
claim: any,
actor: { sub: string; fullName?: string; clientKey?: string },
): Promise<ExpertFileAssignResultDto> {
if (!claim.blameRequestId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This claim has no linked blame file.",
});
}
const blame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
if (!blame) {
throw new NotFoundException("Linked blame file not found.");
}
if (!(blame as any).isMadeByFileMaker) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
});
}
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`,
});
}
const existing = (blame as any).assignedFileReviewerId;
if (existing) {
if (String(existing) === actor.sub) {
return {
success: true,
status: "already_assigned_to_you",
message: "You have already taken this file.",
};
}
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Another FileReviewer has already taken this file.",
});
}
// Atomically claim it — findOneAndUpdate with null/missing guard
const reviewerOid = new Types.ObjectId(actor.sub);
const updated = await this.blameRequestDbService.findOneAndUpdate(
{
_id: (blame as any)._id,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ $set: { assignedFileReviewerId: reviewerOid } },
{ new: true },
);
if (!updated) {
// Another reviewer won the race
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Another FileReviewer has already taken this file.",
});
}
const now = new Date();
this.logger.log(
`FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`,
);
return {
success: true,
status: "assigned",
assignedAt: now.toISOString(),
message: "File assigned to you. Proceed with accident fields and field capture.",
};
}
/**
* Assign the current damage expert to a claim for review (V2).
* Free cases are locked atomically; already-assigned-to-self is idempotent.
*/
async assignClaimForReviewV2(
claimRequestId: string,
actor: {
sub: string;
fullName?: string;
clientKey?: string;
role?: string;
},
): Promise<ExpertFileAssignResultDto> {
if (
(actor as any).role !== RoleEnum.FIELD_EXPERT &&
(actor as any).role !== RoleEnum.FILE_REVIEWER
)
requireActorClientKey(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
// FILE_REVIEWER: assign themselves to the linked blame (V4 flow).
// This is a different path from the damage-expert lock — it operates on the
// blame document and is idempotent (same reviewer can re-assign to themselves).
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
}
await this.assertExpertActorOnClaim(claim, actor);
const isFieldExpertOwner =
(actor as any).role === RoleEnum.FIELD_EXPERT &&
(await this.fieldExpertOwnsClaim(claim, actor));
const isFactorValidationLock =
claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
const isExpertReviewingReentry =
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isResendPending =
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
const isTerminalClaim =
claim.status === ClaimCaseStatus.COMPLETED ||
claim.status === ClaimCaseStatus.CANCELLED;
const isFieldExpertPortfolioLock =
isFieldExpertOwner &&
!isTerminalClaim &&
!isDamageReviewLock &&
!isFactorValidationLock &&
!isExpertReviewingReentry &&
!isResendPending;
if (
!isDamageReviewLock &&
!isFactorValidationLock &&
!isExpertReviewingReentry &&
!isResendPending &&
!isFieldExpertPortfolioLock
) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
claim.workflow,
claim.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
claim.workflow,
),
);
}
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
claim.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
return {
success: true,
status: "already_assigned_to_you",
message: "You already started processing this request",
assignedAt: claim.workflow?.lockedAt
? new Date(claim.workflow.lockedAt as Date).toISOString()
: undefined,
};
}
const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
const actorRoleLabel = isFieldExpertOwner
? ("field_expert" as const)
: ("damage_expert" as const);
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName,
actorRole: actorRoleLabel,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const baseLockUpdate: Record<string, unknown> = {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": expiredAt,
"workflow.lockedBy": lockedByPayload,
...(isDamageReviewLock || isExpertReviewingReentry || isResendPending
? {
"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_ASSIGNED",
actor: {
actorId: expertOid,
actorName: actor.fullName,
actorType,
},
timestamp: now,
metadata: {
note: isFactorValidationLock
? "Expert assigned for factor validation review"
: isFieldExpertPortfolioLock
? "Field expert portfolio lock (no status change)"
: "Expert assigned via review assign endpoint",
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
},
},
},
};
if (!claim.workflow?.assignedForReviewBy) {
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
}
const lockUpdate: Record<string, unknown> =
isFactorValidationLock ||
isExpertReviewingReentry ||
isResendPending ||
isFieldExpertPortfolioLock
? baseLockUpdate
: {
...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId),
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
if (isFactorValidationLock) {
assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW;
assignFilter["workflow.currentStep"] =
ClaimWorkflowStep.EXPERT_COST_EVALUATION;
assignFilter.status = {
$in: [
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
],
};
} else if (isExpertReviewingReentry) {
assignFilter.status = ClaimCaseStatus.EXPERT_REVIEWING;
} else if (isResendPending) {
assignFilter.status = ClaimCaseStatus.WAITING_FOR_USER_RESEND;
} else if (isFieldExpertPortfolioLock) {
assignFilter.initiatedByFieldExpertId = expertOid;
assignFilter.status = claim.status;
} else {
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
}
const updated = await this.claimCaseDbService.findOneAndUpdate(
assignFilter as any,
lockUpdate,
{ new: true },
);
if (!updated) {
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const latest = await this.claimCaseDbService.findById(claimRequestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
}
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(latest) &&
otherId &&
otherId !== actor.sub
) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request could not be assigned",
});
}
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(updated, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
});
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
if (
ownerPhone &&
isDamageReviewLock &&
!isFieldExpertPortfolioLock &&
!isExpertReviewingReentry &&
!isResendPending
) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: ownerPhone,
fileKind: "claim",
publicId: updated.publicId,
expertLastName,
},
);
}
this.logger.log(
`Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
);
return {
success: true,
status: "assigned",
assignedAt: now.toISOString(),
};
}
/**
* 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).
*
* Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients.
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
try {
const result = await this.assignClaimForReviewV2(claimRequestId, actor);
const response: Record<string, unknown> = {
claimRequestId,
locked: true,
message: result.message,
};
if (result.status === "assigned") {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) {
response.status = ClaimCaseStatus.EXPERT_REVIEWING;
response.claimStatus = ClaimStatus.UNDER_REVIEW;
response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
}
}
return response;
} catch (error) {
if (error instanceof ConflictException) {
const body = error.getResponse();
throw new BadRequestException(body);
}
if (error instanceof HttpException) throw error;
this.logger.error(
"lockClaimRequestV2 failed",
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new HttpException(
error instanceof Error ? error.message : "Failed to lock claim",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async submitResendDocsV2(
claimRequestId: string,
reply: ClaimSubmitResendV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
await this.assertExpertActorOnClaim(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 carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const normalizedParts = normalizeResendCarPartsForStorage(
reply.resendCarParts,
carType,
claim.damage?.selectedParts,
);
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,
requestedByExpertId: new Types.ObjectId(actor.sub),
...(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)),
});
}
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`,
});
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,
) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
await this.assertExpertActorOnClaim(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, when enabled.
const priceCap = getClaimV2TotalPaymentCapToman();
if (priceCap !== null) {
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(
String(part.totalPayment ?? "0"),
);
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > priceCap) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
});
}
}
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 seenCatalogIds = new Set<number>();
const expertAddedParts: DamageSelectedPartV2[] = [];
const processedParts = daghiNormalized.map((p) => {
const selected = resolvePartForExpertReply(
p.partId,
ownerSelectedParts,
carTypeSubmit,
);
if (!selected) {
throw new BadRequestException(
`Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`,
);
}
let carPartDamage: Record<string, unknown>;
try {
carPartDamage = normalizeCarPartDamageForExpertReply(
{
id: selected.id,
name: selected.name,
side: selected.side,
label_fa: selected.label_fa,
...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}),
},
carTypeSubmit,
);
} catch (err) {
throw new BadRequestException(
`Invalid part ${p.partId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
if (catalogId == null) {
throw new BadRequestException(
`Invalid part ${p.partId}: a numeric catalog id is required.`,
);
}
if (seenCatalogIds.has(catalogId)) {
throw new BadRequestException(
`Duplicate part submitted in expert reply: ${catalogId}.`,
);
}
seenCatalogIds.add(catalogId);
const wasOnClaim = ownerSelectedParts.some(
(op) => partLookupKey(op) === partLookupKey(selected),
);
if (!wasOnClaim) {
expertAddedParts.push(selected);
}
return {
...p,
partId: catalogId,
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 mergedSelectedParts = [...ownerSelectedParts];
if (expertAddedParts.length > 0) {
const mergedKeys = new Set(
mergedSelectedParts.map((sp) => partLookupKey(sp)),
);
for (const sp of expertAddedParts) {
const k = partLookupKey(sp);
if (!mergedKeys.has(k)) {
mergedKeys.add(k);
mergedSelectedParts.push(sp);
}
}
}
const updatePayload: Record<string, unknown> = {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
...(expertAddedParts.length > 0
? { "damage.selectedParts": mergedSelectedParts }
: {}),
"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)),
});
}
const fanavaranExpertise = !needsFactorUpload
? await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
claimRequestId,
)
: {
attempted: false,
submitted: false,
skipped: true,
skipReason:
"Expertise submit waits until factor-needed repair lines are validated.",
};
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep,
workflowNextStep: needsFactorUpload
? nextWorkflowStep
: ClaimWorkflowStep.CLAIM_COMPLETED,
factorNeeded: needsFactorUpload,
mixedPricingAndFactors: mixedFactorAndPrice,
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
isFinalReplyAfterObjection,
fanavaranExpertise,
};
}
/**
* 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 in this damage experts portfolio, grouped for dashboard.
* Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities.
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
if (actor.role === RoleEnum.FIELD_EXPERT) {
const rows = (await this.claimCaseDbService.find(
{ initiatedByFieldExpertId: new Types.ObjectId(actor.sub) },
{ lean: true },
)) as any[];
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows) {
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
buckets["PENDING_ON_USER"] =
(buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] ?? 0) +
(buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] ?? 0) +
(buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] ?? 0) +
(buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] ?? 0) +
(buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING] ?? 0);
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] +
(buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] ?? 0);
return buckets;
}
const clientKey = requireActorClientKey(actor);
const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId);
const activityEvents = await this.expertFileActivityDbService.findByExpert(
expertId,
ExpertFileKind.CLAIM,
);
const activityFileIds =
expertPortfolioFileIdsFromActivityEvents(activityEvents);
const orClauses: Record<string, unknown>[] = [
{ "workflow.lockedBy.actorId": expertOid },
{ "evaluation.damageExpertReply.actorDetail.actorId": expertOid },
{ "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid },
{ "evaluation.damageExpertResend.requestedByExpertId": expertOid },
];
const activityOids = objectIdsFromStringSet(activityFileIds);
if (activityOids.length > 0) {
orClauses.push({ _id: { $in: activityOids } });
}
const rows =
orClauses.length === 0
? []
: await this.claimCaseDbService.find(
{ $or: orClauses },
{ lean: true },
);
const buckets = initialClaimExpertReportBuckets();
const seen = new Set<string>();
for (const doc of rows as Record<string, unknown>[]) {
const id = String(doc._id ?? "");
if (seen.has(id)) continue;
if (
!claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey)
) {
continue;
}
seen.add(id);
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
// Add new calculated fields for front-end
buckets["PENDING_ON_USER"] =
buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] +
buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] +
buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] +
buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] +
buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING];
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] +
buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT];
return buckets;
}
async getUnifiedFileStatusReportV2(
actor: any,
query: UnifiedFileStatusReportQueryDto = {},
): Promise<UnifiedFileStatusReportDto> {
const { claims, blameById } =
await this.collectClaimPortfolioWithBlame(actor);
const { fromDate, toDate } = parseListDateRange(query.from, query.to);
const filteredClaims = claims.filter((c) => {
if (
!isInListDateRange(
c.createdAt as Date | string | undefined,
fromDate,
toDate,
)
) {
return false;
}
if (!query.fileType) return true;
const blame = c.blameRequestId
? blameById.get(String(c.blameRequestId))
: undefined;
const type =
blame?.type ??
(c as { snapshot?: { accident?: { type?: string } } })?.snapshot
?.accident?.type;
return type === query.fileType;
});
return {
statuses: getUnifiedFileStatusCatalog(),
counts: countUnifiedFileStatuses(
filteredClaims.map((c) => {
const blame = c.blameRequestId
? blameById.get(String(c.blameRequestId))
: undefined;
return {
blameStatus: blame?.status,
claimStatus: c.status,
};
}),
),
};
}
private async collectClaimPortfolioWithBlame(actor: any): Promise<{
claims: any[];
blameById: Map<string, any>;
}> {
let claims: any[] = [];
if (actor.role === RoleEnum.FIELD_EXPERT) {
const expertOid = new Types.ObjectId(actor.sub);
const expertBlameIds = await this.blameRequestDbService
.find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
{ select: "_id", lean: true },
)
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
}
claims = (await this.claimCaseDbService.find({ $or: claimOr })) as any[];
} else {
const clientKey = requireActorClientKey(actor);
const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId);
const activityEvents =
await this.expertFileActivityDbService.findByExpert(
expertId,
ExpertFileKind.CLAIM,
);
const activityFileIds =
expertPortfolioFileIdsFromActivityEvents(activityEvents);
const orClauses: Record<string, unknown>[] = [
{ "workflow.lockedBy.actorId": expertOid },
{ "evaluation.damageExpertReply.actorDetail.actorId": expertOid },
{ "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid },
{ "evaluation.damageExpertResend.requestedByExpertId": expertOid },
];
const activityOids = objectIdsFromStringSet(activityFileIds);
if (activityOids.length > 0) {
orClauses.push({ _id: { $in: activityOids } });
}
const rows =
orClauses.length === 0
? []
: await this.claimCaseDbService.find(
{ $or: orClauses },
{ lean: true },
);
const seen = new Set<string>();
for (const doc of rows as Record<string, unknown>[]) {
const id = String(doc._id ?? "");
if (seen.has(id)) continue;
if (
!claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey)
) {
continue;
}
seen.add(id);
claims.push(doc);
}
}
const blameIds = [
...new Set(
claims
.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: "status publicId" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
return { claims, blameById };
}
private paginateClaimListV2(
list: ClaimListItemV2Dto[],
query: ListQueryV2Dto,
): GetClaimListV2ResponseDto {
let filtered = list;
if (query.unifiedStatus) {
filtered = list.filter(
(item) => item.unifiedFileStatus === query.unifiedStatus,
);
}
const paged = applyListQueryV2(
filtered,
{
publicId: (r) => r.publicId,
createdAt: (r) => r.createdAt,
requestNo: (r) => r.publicId,
status: (r) => r.unifiedFileStatus ?? r.status,
fileType: (r) => r.blameRequestType,
searchExtras: (r) =>
[
r.claimRequestId,
r.currentStep,
r.vehicle?.carName,
r.vehicle?.carModel,
r.blameRequestType,
r.unifiedFileStatus,
r.status,
].filter(Boolean) as string[],
},
query,
);
return {
list: paged.list,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
};
}
/**
* 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,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertClaimListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerClaimListV2(actor, query);
}
if (actor.role === RoleEnum.FILE_MAKER) {
return this.getFileMakerClaimListV2(actor, query);
}
requireActorClientKey(actor);
const actorId = actor.sub;
const clientKey = actor.clientKey as string;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
const claims = await this.claimCaseDbService.find({
$or: [
// Available: waiting, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
"workflow.locked": { $ne: true },
// No persistent assignee (or expired without decision)
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
],
},
// This expert's locked/in-progress claims
{
"workflow.locked": true,
"workflow.lockedBy.actorId": new Types.ObjectId(actorId),
},
// This expert's persistently assigned claims (decided or resend/objection follow-up)
{
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
},
// Factor validation queue (open to all tenant experts — no lock needed)
{
$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,
},
],
},
// WAITING_FOR_USER_RESEND assigned to this expert
{
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
},
// EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime)
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
"workflow.locked": { $ne: true },
},
],
});
const filtered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
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);
}
}
// Post-filter: remove files that belong to another expert after reconciliation
const finalFiltered = filtered.filter((c) => {
const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? "");
const lockEnforced =
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c);
const lockedById = String(c.workflow?.lockedBy?.actorId ?? "");
const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
// Factor validation is open to all tenant experts
if (isFactorValidation) return true;
// Available: no assignee, not locked by someone else
const isAvailable =
c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT &&
!assignedId &&
(!lockEnforced || lockedById === actorId);
// Mine: assigned to me (covers decided, resend, objection follow-up)
const isAssignedToMe = !!assignedId && assignedId === actorId;
// Mine: currently locked by me
const isLockedByMe = lockEnforced && lockedById === actorId;
// Mine: resend pending and I'm the assigned expert
const isResendMine =
c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe;
return isAvailable || isAssignedToMe || isLockedByMe || isResendMine;
});
const blameIds = [
...new Set(
finalFiltered
.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 status isMadeByFileMaker" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
const list = finalFiltered.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,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
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,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation,
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* Claim list for a FIELD_EXPERT — returns only the claims they personally
* initiated (expert-initiated IN_PERSON blame → createClaimFromBlameForExpertV2).
*/
private async getFieldExpertClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const expertOid = new Types.ObjectId(actor.sub);
const expertBlameIds = await this.blameRequestDbService
.find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
{ select: "_id", lean: true },
)
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
}
const claims = (await this.claimCaseDbService.find({
$or: claimOr,
})) as any[];
const blameIds = [
...new Set(
claims
.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 status" },
)) as any[])
: [];
const blameById = new Map<string, any>(
blames.map((b) => [String(b._id), b]),
);
const list = claims.map((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)
);
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
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,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* Claim list for FILE_REVIEWER.
*
* Visibility rules:
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
* (a) not yet assigned to any reviewer, OR
* (b) already assigned to THIS reviewer
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer
* (they completed the field work and the file is now in damage-expert queue)
*
* All scoped to this reviewer's insurance company via blame party clientId.
*/
private async getFileReviewerClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const clientKey = requireActorClientKey(actor);
const reviewerOid = new Types.ObjectId(actor.sub);
// Find V4 blame files visible to this reviewer
const blames = (await this.blameRequestDbService.find(
{
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
$or: [
// Open: sealed by FileMaker, not yet taken by any reviewer
{
status: "WAITING_FOR_FILE_REVIEWER",
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
// Taken by this reviewer (any post-assignment status)
{ assignedFileReviewerId: reviewerOid },
],
},
{
lean: true,
select:
"_id type parties blameStatus status expert.decision assignedFileReviewerId",
},
)) as any[];
if (blames.length === 0) {
return this.paginateClaimListV2([], query);
}
// Scope to this reviewer's insurer via blame party clientId
const scopedBlames = blames.filter((b) =>
blameCaseTouchesClient(b, clientKey),
);
if (scopedBlames.length === 0) {
return this.paginateClaimListV2([], query);
}
const scopedBlameIds = scopedBlames.map((b) => b._id);
const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: scopedBlameIds },
})) as any[];
const blameById = new Map<string, any>(
scopedBlames.map((b) => [String(b._id), b]),
);
const list = claims.map((c) => {
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
const assignedToMe =
blame?.assignedFileReviewerId &&
String(blame.assignedFileReviewerId) === actor.sub;
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
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,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
assignedToMe: !!assignedToMe,
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* Claim list for FILE_MAKER — returns only the claims linked to blame files
* they personally created (V4 flow).
*/
private async getFileMakerClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
const makerOid = new Types.ObjectId(actor.sub);
const makerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" },
)) as any[];
if (makerBlames.length === 0) {
return this.paginateClaimListV2([], query);
}
const blameIds = makerBlames.map((b) => b._id);
const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: blameIds },
})) as any[];
const blameById = new Map<string, any>(
makerBlames.map((b) => [String(b._id), b]),
);
const list = claims.map((c) => {
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: blame?.status,
claimStatus: c.status,
}),
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,
blameRequestId: c.blameRequestId?.toString(),
createdAt: c.createdAt,
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
needsFileReviewerCompletion:
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
(!!blame?.isMadeByFileMaker &&
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
assignedFileReviewerId: blame?.assignedFileReviewerId
? String(blame.assignedFileReviewerId)
: undefined,
};
}) as ClaimListItemV2Dto[];
return this.paginateClaimListV2(list, query);
}
/**
* 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() + EXPERT_WORKFLOW_LOCK_TTL_MS;
}
/**
* 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;
role?: string;
}): Promise<void> {
if (
(actor as any).role === RoleEnum.FIELD_EXPERT ||
(actor as any).role === RoleEnum.FILE_REVIEWER ||
(actor as any).role === RoleEnum.FILE_MAKER
) return;
const clientKey = requireActorClientKey(actor);
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
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 = EXPERT_WORKFLOW_LOCK_TTL_MS;
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 assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
claim.workflow,
claim,
);
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const update = needsQueueRestore
? {
$set: {
...expireLockSet,
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: restoreClaimStatus,
"workflow.currentStep": restoreCurrent,
"workflow.nextStep": restoreNext,
},
$unset: lockFieldsUnset,
}
: {
$set: expireLockSet,
$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 (30 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;
if (
actor.role !== RoleEnum.FIELD_EXPERT &&
actor.role !== RoleEnum.FILE_MAKER
) {
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
}
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
const linkedBlame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
// Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim);
// FileMaker: can always view their own V4 files at any status.
if (actor.role === RoleEnum.FILE_MAKER) {
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
if (!isOwn) {
throw new ForbiddenException(
"FileMakers can only view files they created.",
);
}
// Fall through to the detail build below
} else if (
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
// regardless of claim status (they work across multiple non-standard steps).
actor.role === RoleEnum.FIELD_EXPERT ||
actor.role === RoleEnum.FILE_REVIEWER
) {
if (actor.role === RoleEnum.FILE_REVIEWER) {
// FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
const isV4Blame =
(linkedBlame as any)?.isMadeByFileMaker &&
linkedBlame?.expertInitiated &&
linkedBlame?.creationMethod === "IN_PERSON";
if (!isV4Blame) {
throw new ForbiddenException(
"FileReviewers can only access V4 FileMaker files.",
);
}
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
? String((linkedBlame as any).assignedFileReviewerId)
: null;
const isAssignedToMe =
assignedReviewerId && assignedReviewerId === actorId;
const isOpen =
!assignedReviewerId &&
String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER";
if (!isAssignedToMe && !isOpen) {
throw new ForbiddenException(
"This file has been taken by another reviewer.",
);
}
} else if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
throw new ForbiddenException("This claim is not accessible to you.");
}
// Fall through to the detail build below (skip the damage-expert gate)
} else if (actor.role !== RoleEnum.FILE_MAKER) {
const isResendPending =
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
const assignedForReviewById = String(
(claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
);
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === actorId;
// Gate: must satisfy at least one bucket
if (
!isDamageExpertPhase &&
!isFactorValidationPending &&
!isResendPending &&
!isAssignedToMe
) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
const isAvailable =
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
const isLockedByMe = lockEnforced && lockedById === actorId;
const isFactorValidationOpen = isFactorValidationPending;
if (
!isAvailable &&
!isAssignedToMe &&
!isLockedByMe &&
!isFactorValidationOpen
) {
if (lockEnforced && lockedById && lockedById !== actorId) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`,
);
}
if (assignedForReviewById && assignedForReviewById !== actorId) {
throw new ForbiddenException(
"This claim has been assigned to another expert.",
);
}
throw new ForbiddenException(
"You do not have permission to view this claim.",
);
}
} // end damage-expert gate (else block)
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
// Resend documents requested by expert — normalize to canonical keys for comparison
const resendDocumentKeys = new Set(
normalizeResendDocumentKeys(
(claim as any).evaluation?.damageExpertResend?.resendDocuments,
),
);
const resendFulfilled = !!(claim as any).evaluation?.damageExpertResend
?.fulfilledAt;
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];
const canonicalKey = canonicalizeResendDocumentKey(k) ?? k;
const wasResendRequested = resendDocumentKeys.has(canonicalKey);
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
resend: {
wasRequested: wasResendRequested,
isFulfilled: wasResendRequested && resendFulfilled,
},
};
}
}
// 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: resolveStoredFileUrl(cap),
};
}
// Build enriched damaged 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 = buildEnrichedDamagedParts({
selectedParts: selectedNormExpert,
damagedPartsData: damagedPartsDataExpert,
evaluationBlock: (claim as any).evaluation,
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
getDamagedPartCaptureBlob,
hasDamagedPartCapture,
catalogLikeKeyFromPart,
buildFileLink,
resolveStoredFileUrl,
});
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
let vehiclePayload = claim.vehicle as any;
const hasClaimVehicle =
vehiclePayload &&
(vehiclePayload.carName ||
vehiclePayload.carModel ||
vehiclePayload.carType ||
vehiclePayload.plate);
let blameLean: any = null;
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 (!hasClaimVehicle && blameLean) {
const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim);
if (fromBlame) vehiclePayload = fromBlame;
}
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
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 blameCaseForApi = blameCase
? this.sanitizeBlameCaseForClaimDetailApi(
blameCase as Record<string, unknown>,
)
: undefined;
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 (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) {
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
}
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,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
awaitingFactorValidation: isFactorValidationPending,
evaluation:
(evaluationForApi as
| ClaimDetailV2ResponseDto["evaluation"]
| undefined) || (enrichedEvaluation as any),
videoCapture,
blameCase: blameCaseForApi,
blameExpertDecision,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
/** V2 price-drop: claim locked by this expert during damage assessment. */
private async assertExpertCanEditClaimDuringReviewV2(
claim: any,
actor: any,
): Promise<void> {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before entering price-drop data",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== String(actor.sub)) {
throw new ForbiddenException("This claim is locked by another expert");
}
}
/**
* V2: Context for manual price-drop (vehicle, inquiry year, catalog, damaged parts).
*/
async getPriceDropContextV2(claimRequestId: string, actor: any) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carType,
(claim.damage as any)?.selectedOuterParts,
);
let suggestedCarModelYear: number | null = null;
if (claim.blameRequestId) {
const blameRows = await this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{
lean: true,
select: "parties.role parties.person.userId parties.vehicle.inquiry",
},
);
const blame = blameRows[0] as Record<string, unknown> | undefined;
suggestedCarModelYear = resolveVehicleModelYearFromBlame(
claim,
blame as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
);
}
const existing = (claim as any).evaluation?.priceDrop;
return {
claimRequestId: String(claim._id),
vehicle: {
carName: claim.vehicle?.carName,
carModel: claim.vehicle?.carModel,
carType: claim.vehicle?.carType,
},
suggestedCarModelYear,
priceDrop: existing ?? null,
severityOptions: PRICE_DROP_SEVERITY_OPTIONS,
priceDropCatalog: buildPriceDropCatalogForApi(),
damagedParts: buildDamagedPartsPriceDropLines(selectedNorm),
};
}
/**
* V2: Expert enters vehicle + per-part severities; persists `vehicle` and `evaluation.priceDrop`.
*/
async upsertPriceDropV2(
claimRequestId: string,
body: UpsertClaimPriceDropV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
const vehicleSet: Record<string, string> = {};
if (body.carName != null && String(body.carName).trim()) {
vehicleSet["vehicle.carName"] = String(body.carName).trim();
}
if (body.carModel != null && String(body.carModel).trim()) {
vehicleSet["vehicle.carModel"] = String(body.carModel).trim();
}
// Manual override path — expert knows the number, skip calculation entirely
if (body.manualPriceDrop != null) {
if (!Number.isFinite(body.manualPriceDrop) || body.manualPriceDrop < 0) {
throw new BadRequestException(
"manualPriceDrop must be a non-negative finite number.",
);
}
// Manual path — change totalPriceDrop → total to match calculated path
const manualPayload = {
manualOverride: true,
total: body.manualPriceDrop, // ← was totalPriceDrop
partLines: [],
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
...vehicleSet,
"evaluation.priceDrop": manualPayload,
},
});
const updated = await this.claimCaseDbService.findById(claimRequestId);
return {
claimRequestId,
vehicle: {
carName: updated?.vehicle?.carName,
carModel: updated?.vehicle?.carModel,
carType: updated?.vehicle?.carType,
},
priceDrop: manualPayload,
partLines: [],
};
}
// Calculated path — existing logic unchanged
// At the top of the calculated path, after the manualPriceDrop early return:
const existingPriceDrop = (claim as any).evaluation?.priceDrop;
if (
existingPriceDrop?.manualOverride === true &&
!body.partSeverities?.length
) {
throw new BadRequestException(
"This claim has a manual price-drop correction. " +
"To recalculate, provide partSeverities explicitly — this will replace the manual correction.",
);
}
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carType,
(claim.damage as any)?.selectedOuterParts,
);
if (!body.partSeverities?.length) {
throw new BadRequestException(
"Provide at least one partSeverities line, or use manualPriceDrop for a direct override.",
);
}
let carModelYear = body.carModelYear;
if (carModelYear == null && claim.blameRequestId) {
const blameRows = await this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{
lean: true,
select: "parties.role parties.person.userId parties.vehicle.inquiry",
},
);
carModelYear =
resolveVehicleModelYearFromBlame(
claim,
blameRows[0] as Parameters<
typeof resolveVehicleModelYearFromBlame
>[1],
) ?? undefined;
}
if (carModelYear == null || !Number.isFinite(carModelYear)) {
throw new BadRequestException(
"carModelYear is required when it cannot be resolved from the linked blame plate inquiry.",
);
}
const { coefficients, lines, errors } = buildCoefficientsFromPartSeverities(
selectedNorm,
body.partSeverities.map((p) => ({
partId: p.partId,
severity: p.severity,
})),
carType,
);
if (errors.length > 0) {
throw new BadRequestException(errors.join(" "));
}
if (coefficients.length === 0) {
throw new BadRequestException(
"No valid severity coefficients could be derived from partSeverities.",
);
}
const calculated = calculateClaimPriceDrop(
body.carPrice,
carModelYear,
coefficients,
);
const priceDropPayload = {
...calculated,
partLines: lines,
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
...vehicleSet,
"evaluation.priceDrop": priceDropPayload,
},
});
const updated = await this.claimCaseDbService.findById(claimRequestId);
return {
claimRequestId,
vehicle: {
carName: updated?.vehicle?.carName,
carModel: updated?.vehicle?.carModel,
carType: updated?.vehicle?.carType,
},
priceDrop: priceDropPayload,
partLines: lines,
};
}
/**
* 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,
) {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
await this.assertExpertActorOnClaim(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 nextNormRaw = body.selectedParts.map((p) =>
sanitizeDamageSelectedPartV2(
{
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() } : {}),
},
carType,
),
);
// Drop ghost entries: internal parts with neither a catalog id nor a name
// (these are frontend artifacts from uninitialised form rows).
const nextNorm = nextNormRaw.filter((p) => {
if (p.id != null) return true;
return String(p.name ?? "").trim().length > 0;
});
if (nextNorm.length === 0) {
throw new BadRequestException(
"selectedParts must contain at least one identifiable part. " +
"Each entry must have a numeric catalog id or a non-empty name.",
);
}
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]
: [];
// Diff to find parts the expert is adding that weren't in the original selection
const previousPartIds = new Set(
previousNorm.map((p) => p.id ?? `${p.name}::${p.side}`),
);
const expertAddedNow = nextNorm.filter(
(p) => !previousPartIds.has(p.id ?? `${p.name}::${p.side}`),
);
// Merge with parts the expert added in any previous edits on this claim (idempotent)
const existingExpertAdded: any[] =
(claim.damage as any)?.expertAddedParts ?? [];
const existingExpertAddedIds = new Set(
existingExpertAdded.map((p: any) => p.id ?? `${p.name}::${p.side}`),
);
const expertAddedToAppend = expertAddedNow.filter(
(p) => !existingExpertAddedIds.has(p.id ?? `${p.name}::${p.side}`),
);
const mergedExpertAdded = [...existingExpertAdded, ...expertAddedToAppend];
const $set: Record<string, unknown> = {
"damage.selectedParts": nextNorm,
"media.damagedParts": nextMedia,
"damage.expertAddedParts": mergedExpertAdded,
};
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,
expertAddedParts: mergedExpertAdded,
...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
},
});
return {
claimRequestId: claim._id.toString(),
selectedParts: nextNorm,
previousSelectedParts: previous,
expertAddedParts: mergedExpertAdded,
message: "Damaged parts updated successfully.",
};
}
}