Files
yara724api/src/expert-insurer/expert-insurer.service.ts
SepehrYahyaee fc37dd78a4 Fixed FaLabels
2026-09-12 11:58:07 +03:30

2888 lines
94 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
CreateInsurerExpertDto,
} from "./dto/create-insurer-expert.dto";
import {
blameCaseTouchesClient,
claimCaseTouchesClient,
} from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import { HashService } from "src/utils/hash/hash.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
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 { 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 { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
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 {
getClaimCarAngleCaptureBlob,
getDamagedPartCaptureBlob,
hasDamagedPartCapture,
type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media";
import {
catalogLikeKeyFromPart,
normalizeDamageSelectedParts,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
import {
extractExpertNamesFromBlame,
extractExpertNamesFromClaim,
} from "./helper/insurer.helper";
import {
getEventFaDescription,
getEventFaLabel,
localizeTimelineMetadata,
} from "./helper/timeline-fa-labels";
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
@Injectable()
export class ExpertInsurerService {
constructor(
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly branchDbService: BranchDbService,
private readonly hashService: HashService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly videoCaptureDbService: VideoCaptureDbService,
private readonly clientDbService: ClientDbService,
private readonly claimSignDbService: ClaimSignDbService,
private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly callCenterAgentDbService: CallCenterAgentDbService,
private readonly userDbService: UserDbService,
) {}
/**
* Returns all expert IDs (blame-expert, damage-expert, field-expert) that
* belong to this insurer's clientKey in a single parallel fetch.
*/
private async getAllRosterExpertIds(
clientObjectId: Types.ObjectId,
): Promise<Set<string>> {
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const [experts, damageExperts, fieldExperts] = await Promise.all([
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
this.fieldExpertDbService.findAll(ckFilter as never),
]);
return new Set<string>(
[...experts, ...damageExperts, ...fieldExperts].map((e: any) =>
String(e._id),
),
);
}
private getClientId(actorOrId: any): Types.ObjectId {
const raw =
typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
if (!raw || !Types.ObjectId.isValid(raw)) {
throw new BadRequestException("Client key is required");
}
return new Types.ObjectId(raw);
}
/**
* Tenant scope on expert / damage-expert: `clientKey` may be stored as ObjectId or
* string (legacy and collection-specific writes). Match both, same as reports service.
*/
private clientKeyScopeFilter(clientObjectId: Types.ObjectId) {
const oid = clientObjectId;
const str = String(oid);
return { $or: [{ clientKey: oid }, { clientKey: str }] };
}
private async buildExpertActivityStatsMap(
tenantId: Types.ObjectId,
expertIds: string[],
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
const events =
await this.expertFileActivityDbService.findByTenantAndExperts(
tenantId,
expertIds,
);
const stateByExpertFile = new Map<
string,
{ checked: boolean; handled: boolean }
>();
for (const e of events) {
const key = `${String(e.expertId)}:${String(e.fileId)}`;
const prev = stateByExpertFile.get(key) ?? {
checked: false,
handled: false,
};
if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
}
stateByExpertFile.set(key, prev);
}
const result: Record<
string,
{ totalHandled: number; totalChecked: number }
> = {};
for (const expertId of expertIds) {
result[expertId] = { totalHandled: 0, totalChecked: 0 };
}
for (const [key, st] of stateByExpertFile.entries()) {
const [expertId] = key.split(":");
if (!result[expertId])
result[expertId] = { totalHandled: 0, totalChecked: 0 };
if (st.handled) result[expertId].totalHandled += 1;
else if (st.checked) result[expertId].totalChecked += 1;
}
return result;
}
private parseObjectId(value: string, label: string): Types.ObjectId {
if (!Types.ObjectId.isValid(value)) {
throw new BadRequestException(`Invalid ${label}`);
}
return new Types.ObjectId(value);
}
private normalizeClaimCase(claim: any): any {
return {
...claim,
requestNumber: claim.requestNo,
userClientKey: claim.owner?.userClientKey,
fullName: claim.owner?.fullName,
carDetail: claim.vehicle,
carPlate: claim.vehicle?.plate,
claimStatus: claim.status,
rating: claim.evaluation?.rating,
userRating: claim.evaluation?.userRating,
damageExpertReply: claim.evaluation?.damageExpertReply,
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
damageExpertResend: claim.evaluation?.damageExpertResend,
objection: claim.evaluation?.objection,
userResendDocuments: claim.evaluation?.userResendDocuments,
priceDrop: claim.evaluation?.priceDrop,
visitLocation: claim.evaluation?.visitLocation,
currentStep: claim.workflow?.currentStep,
nextStep: claim.workflow?.nextStep,
};
}
private normalizeBlameCase(blame: any): any {
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
return {
...blame,
requestNumber: blame.requestNo,
rating: blame?.expert?.rating,
actorLocked: { actorId: blame?.expert?.assignedExpertId },
firstPartyDetails: {
firstPartyClient: { clientId: firstParty?.person?.clientId },
},
secondPartyDetails: {
secondPartyClient: { clientId: secondParty?.person?.clientId },
},
};
}
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
private blameFieldExpertIdCandidates(b: any): string[] {
const raw: unknown[] = [
b?.expert?.assignedExpertId,
b?.initiatedByFieldExpertId,
b?.expert?.decision?.decidedByExpertId,
];
const out = new Set<string>();
for (const x of raw) {
if (x != null && x !== "") out.add(String(x));
}
return [...out];
}
private claimDamageExpertActorId(c: any): string | null {
const reply =
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
const aid = reply?.actorDetail?.actorId;
if (aid == null || aid === "") return null;
return String(aid);
}
private mapBlameFileSummaryForInsurerExpert(b: any) {
return {
kind: "blame" as const,
requestId: String(b._id),
publicId: b.publicId,
requestNo: b.requestNo,
type: b.type,
status: b.status,
blameStatus: b.blameStatus,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
};
}
private mapClaimFileSummaryForInsurerExpert(c: any) {
return {
kind: "claim" as const,
requestId: String(c._id),
publicId: c.publicId,
requestNo: c.requestNo ?? c.requestNumber,
status: c.status,
claimStatus: c.claimStatus,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
}
private buildPartiesPreview(parties: any[] | undefined) {
if (!Array.isArray(parties) || !parties.length) return undefined;
return parties.map((p: any) => ({
role: p?.role,
fullName: p?.person?.fullName,
vehicle: {
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
},
}));
}
private sanitizeVehicleInquiry(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;
return {
...vehicle,
inquiry: safeInquiry,
};
}
private sanitizePartyForInsurerView(party: any) {
if (!party || typeof party !== "object") return party;
return {
...party,
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
};
}
private serializeBlameExpertDecisionForInsurer(
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,
};
}
/** Same semantics as damage-expert claim detail `blameFileContext`. */
private blameFileContextForInsurer(blame: Record<string, unknown> | null): {
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 as string | undefined;
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
const parties = blame.parties;
if (!Array.isArray(parties) || parties.length === 0) return out;
const first =
(parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ??
(parties as any[])[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 async clientNamesByClientIds(
ids: Array<string | undefined | null>,
): Promise<Record<string, { persian: string; english: string }>> {
const unique = [
...new Set(
ids
.filter((x): x is string => !!x && Types.ObjectId.isValid(String(x)))
.map(String),
),
];
const out: Record<string, { persian: string; english: string }> = {};
await Promise.all(
unique.map(async (id) => {
const doc = await this.clientDbService.find({
_id: new Types.ObjectId(id),
});
if (doc?.clientName) out[id] = doc.clientName;
}),
);
return out;
}
private mapPersonForInsurer(
person: any,
clientNames: Record<string, { persian: string; english: string }>,
) {
if (!person || typeof person !== "object") return person;
const cid =
person.clientId?.toString?.() ??
(person.clientId != null ? String(person.clientId) : undefined);
return {
...person,
userId: person.userId?.toString?.() ?? undefined,
clientId: cid,
clientName: cid ? clientNames[cid] : undefined,
};
}
private mapPartyForInsurerFull(
party: any,
clientNames: Record<string, { persian: string; english: string }>,
) {
if (!party || typeof party !== "object") return party;
return {
...party,
person: this.mapPersonForInsurer(party.person, clientNames),
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
};
}
/** Public URL for a stored relative path or pass-through if already absolute. */
private linkFromPathOrUrl(stored: string | undefined): string | undefined {
if (stored == null || stored === "") return undefined;
const s = String(stored);
if (/^https?:\/\//i.test(s)) return s;
return buildFileLink(s);
}
private enrichSignatureRecord(
sig: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!sig || typeof sig !== "object") return sig;
const fileUrl = sig.fileUrl != null ? String(sig.fileUrl) : undefined;
const link = this.linkFromPathOrUrl(fileUrl);
return { ...sig, link };
}
private enrichPartiesConfirmationSignatures(parties: any[]): void {
if (!Array.isArray(parties)) return;
for (const p of parties) {
const sig = p?.confirmation?.signature;
if (sig && typeof sig === "object") {
p.confirmation = {
...p.confirmation,
signature: this.enrichSignatureRecord(sig as Record<string, unknown>),
};
}
}
}
private insurerDamagedPartRowFromCapture(
sp: DamageSelectedPartV2,
index: number,
damagedPartsDataExpert: unknown,
selectedNormExpert: DamageSelectedPartV2[],
) {
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
const cap = getDamagedPartCaptureBlob(
damagedPartsDataExpert,
ck,
selectedNormExpert,
) as { url?: string; path?: string; fileName?: string } | undefined;
const url = resolveStoredFileUrl(cap);
const link = url;
return {
index,
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
catalogKey: sp.catalogKey,
captured: hasDamagedPartCapture(
damagedPartsDataExpert,
ck,
selectedNormExpert,
),
path: cap?.path,
fileName: cap?.fileName,
url,
link,
};
}
private sanitizeDamageForInsurerDetail(damage: unknown): unknown {
if (!damage || typeof damage !== "object") return damage;
const d = { ...(damage as Record<string, unknown>) };
delete d.selectedOuterParts;
delete d.selectedPartIds;
return d;
}
private stripCarTypeFromPartyVehicles(parties: any[]): any[] {
if (!Array.isArray(parties)) return parties;
return parties.map((p) => {
const v = p?.vehicle;
if (!v || typeof v !== "object") return p;
const { carType: _omit, ...rest } = v as Record<string, unknown>;
const nextV = Object.keys(rest).length ? rest : undefined;
return { ...p, vehicle: nextV };
});
}
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;
}
private async enrichClaimEvaluationForInsurer(
evaluation: Record<string, unknown> | undefined,
): Promise<Record<string, unknown> | undefined> {
if (!evaluation || typeof evaluation !== "object") return evaluation;
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<
string,
unknown
>;
const enrichReply = async (reply: any) => {
if (!reply || typeof reply !== "object") return;
const uc = reply.userComment;
if (uc?.signDetailId != null) {
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
reply.userComment = { ...uc, signLink };
}
};
await enrichReply(ev.damageExpertReply);
await enrichReply(ev.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;
}
private async enrichBlamePartyEvidenceUrls(parties: any[]): Promise<void> {
if (!Array.isArray(parties)) return;
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;
}
}
}
private async buildInsurerBlameDetail(
blameLean: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const doc: Record<string, unknown> = { ...blameLean };
const parties = (Array.isArray(doc.parties) ? doc.parties : []) as any[];
await this.enrichBlamePartyEvidenceUrls(parties);
doc.parties = enrichBlamePartiesForAgreementView(doc);
const mergedParties = doc.parties as any[];
const clientIds = mergedParties
.map((p) => p?.person?.clientId?.toString?.())
.filter(Boolean);
const clientNames = await this.clientNamesByClientIds(clientIds);
doc.parties = mergedParties.map((p) =>
this.mapPartyForInsurerFull(p, clientNames),
);
this.enrichPartiesConfirmationSignatures(doc.parties as any[]);
if (doc.expert && typeof doc.expert === "object") {
const ex = { ...(doc.expert as Record<string, unknown>) };
const serialized = this.serializeBlameExpertDecisionForInsurer(
ex.decision,
);
if (serialized !== undefined) ex.decision = serialized;
doc.expert = ex;
}
const createdAt = doc.createdAt
? new Date(doc.createdAt as string | number | Date)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number | Date)
: new Date();
const [cd, ct] = toJalaliDateAndTime(createdAt);
const [ud, ut] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${cd} ${ct}`;
doc.updatedAtFormatted = `${ud} ${ut}`;
delete doc.history;
return doc;
}
private async buildInsurerClaimDetail(
claim: Record<string, unknown>,
blameForContext: Record<string, unknown> | null,
): Promise<Record<string, unknown>> {
// Add at the top of buildInsurerClaimDetail, before the return:
const carType = (claim as any).vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const selectedNorm = normalizeDamageSelectedParts(
(claim as any).damage?.selectedParts,
carType,
(claim as any).damage?.selectedOuterParts,
);
const damagedParts = buildEnrichedDamagedParts({
selectedParts: selectedNorm,
damagedPartsData: (claim as any).media?.damagedParts,
evaluationBlock: (claim as any).evaluation,
expertAddedParts: (claim as any).damage?.expertAddedParts ?? [],
getDamagedPartCaptureBlob,
hasDamagedPartCapture,
catalogLikeKeyFromPart,
buildFileLink,
});
// Then add damagedParts to the return object
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
if (requiredDocs) {
const keys =
requiredDocs instanceof Map
? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs);
for (const k of keys as string[]) {
const row =
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!row?.uploaded,
fileId: row?.fileId?.toString?.(),
fileName: row?.fileName,
fileUrl: row?.filePath ? buildFileLink(row.filePath) : undefined,
};
}
}
const carAnglesData = (claim.media as any)?.carAngles;
const damagedPartsDataForAngles = (claim.media as any)?.damagedParts;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ["front", "back", "left", "right"] as ClaimCarAngleKey[]) {
const cap = getClaimCarAngleCaptureBlob(
carAnglesData,
damagedPartsDataForAngles,
k,
) as { url?: string; path?: string } | undefined;
carAngles[k] = {
captured: !!cap,
url: resolveStoredFileUrl(cap),
};
}
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
let videoCapture:
| { id: string; fileName?: string; path?: string; url?: string }
| undefined;
if (videoCaptureIdRaw) {
const vc = (await this.videoCaptureDbService.findById(
String(videoCaptureIdRaw),
)) as any;
if (vc) {
videoCapture = {
id: String(vc._id ?? videoCaptureIdRaw),
fileName: vc.fileName,
path: vc.path,
url: vc.path ? buildFileLink(vc.path) : undefined,
};
}
}
const evaluation = claim.evaluation as Record<string, unknown> | undefined;
const owner = claim.owner as any;
const ownerOut = owner
? {
...owner,
userId: owner.userId?.toString?.(),
userClientKey: owner.userClientKey?.toString?.(),
}
: undefined;
let snapshotOut = claim.snapshot as any;
if (snapshotOut && Array.isArray(snapshotOut.parties)) {
const snapIds = snapshotOut.parties
.map((p: any) => p?.person?.clientId?.toString?.())
.filter(Boolean);
const snapNames = await this.clientNamesByClientIds(snapIds);
const snapParties = this.stripCarTypeFromPartyVehicles(
snapshotOut.parties.map((p: any) =>
this.mapPartyForInsurerFull(p, snapNames),
),
);
this.enrichPartiesConfirmationSignatures(snapParties);
snapshotOut = {
...snapshotOut,
parties: snapParties,
};
}
const evaluationEnriched =
await this.enrichClaimEvaluationForInsurer(evaluation);
const vehicleSanitized = claim.vehicle
? this.sanitizeVehicleInquiry(claim.vehicle as any)
: undefined;
const vehicleOut = vehicleSanitized
? (() => {
const { carType: _omit, ...rest } = vehicleSanitized as Record<
string,
unknown
>;
return rest;
})()
: undefined;
return {
claimRequestId: (claim._id as any)?.toString?.(),
publicId: claim.publicId,
status: claim.status,
claimStatus: claim.claimStatus,
blameDocumentResendPending: claim.blameDocumentResendPending,
workflow: claim.workflow,
owner: ownerOut,
vehicle: vehicleOut,
blameRequestId: (claim as any).blameRequestId?.toString?.(),
blameRequestNo: claim.blameRequestNo,
blameFileContext: blameForContext
? this.blameFileContextForInsurer(blameForContext)
: undefined,
money: claim.money,
damage: this.sanitizeDamageForInsurerDetail(claim.damage),
media: claim.media,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
videoCapture,
evaluation: evaluationEnriched,
userRating: claim.userRating,
insurerRating: (evaluationEnriched as any)?.rating,
snapshot: snapshotOut,
createdAt: claim.createdAt,
updatedAt: claim.updatedAt,
};
}
private getCombinedFileScore(file: any): number | null {
const insurerRating = file?.rating;
const userRating = file?.userRating;
const insurerValues = insurerRating
? (
[
insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
] as unknown[]
).filter((v): v is number => typeof v === "number" && !isNaN(v))
: [];
const userValues = userRating
? [
userRating.progressSpeed,
userRating.registrationEase,
userRating.overallEvaluation,
].filter((v) => typeof v === "number" && !isNaN(v))
: [];
const insurerAvg = insurerValues.length
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
: null;
const userAvg = userValues.length
? userValues.reduce((a, b) => a + b, 0) / userValues.length
: null;
const scores = [insurerAvg, userAvg].filter(
(v): v is number => typeof v === "number",
);
if (!scores.length) return null;
return parseFloat(
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2),
);
}
private async getClientBlameFiles(
clientObjectId: Types.ObjectId,
): Promise<any[]> {
const all = (await this.blameRequestDbService.find(
{},
{ lean: true },
)) as any[];
const idStr = String(clientObjectId);
return all
.filter((f) =>
(f?.parties || []).some(
(p) => String(p?.person?.clientId || "") === idStr,
),
)
.map((f) => this.normalizeBlameCase(f));
}
private async getClientClaimFiles(
clientObjectId: Types.ObjectId,
): Promise<any[]> {
const [all, rosterExpertIds] = await Promise.all([
this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>,
this.getAllRosterExpertIds(clientObjectId),
]);
const idStr = String(clientObjectId);
return all
.filter((f) => {
// Primary: owner.clientId matches this insurer (guilty party's insurance)
if (claimCaseTouchesClient(f, idStr)) return true;
// Secondary: initiated or evaluated by one of this insurer's experts
// (covers field-expert IN_PERSON flow where owner.clientId ≠ this insurer)
const fieldExpertId = f?.initiatedByFieldExpertId
? String(f.initiatedByFieldExpertId)
: null;
if (fieldExpertId && rosterExpertIds.has(fieldExpertId)) return true;
const damageExpertId = this.claimDamageExpertActorId(f);
if (damageExpertId && rosterExpertIds.has(damageExpertId)) return true;
return false;
})
.map((f) => this.normalizeClaimCase(f));
}
private startOfDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
0,
0,
0,
0,
);
}
private endOfDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
23,
59,
59,
999,
);
}
private toUtcDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
const parsed = Number(value ?? 30);
if (parsed === 30 || parsed === 60 || parsed === 90) {
return parsed;
}
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
}
private serializeReportRange(range: {
mode: "preset" | "custom";
fromDate: Date;
toDate: Date;
periodDays?: 30 | 60 | 90;
}) {
return {
mode: range.mode,
from: range.fromDate.toISOString(),
to: range.toDate.toISOString(),
...(range.periodDays ? { periodDays: range.periodDays } : {}),
};
}
private resolveReportDateRange(opts: {
from?: string;
to?: string;
periodDays?: string | number;
} = {}) {
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
const periodDays = this.parseReportPeriodDays(opts.periodDays);
const now = new Date();
if (!hasFrom && !hasTo) {
const toDate = this.endOfDay(now);
const fromBase = new Date(toDate);
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
const fromDate = this.startOfDay(fromBase);
return {
mode: "preset" as const,
fromDate,
toDate,
periodDays,
};
}
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
if (!fromDate && toDate) {
const fromBase = new Date(toDate);
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
fromDate = this.startOfDay(fromBase);
}
if (fromDate && !toDate) {
toDate = this.endOfDay(now);
}
if (!fromDate || !toDate) {
throw new BadRequestException("Could not resolve reporting date range");
}
if (fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return {
mode: "custom" as const,
fromDate,
toDate,
periodDays,
};
}
private buildExpertFileStateMap(
events: Array<{
expertId: Types.ObjectId | string;
fileId: Types.ObjectId | string;
eventType: ExpertFileActivityType;
occurredAt: Date | string;
}>,
cutoff: Date,
): Map<string, { checked: boolean; handled: boolean; handledAt?: Date }> {
const stateByExpertFile = new Map<
string,
{ checked: boolean; handled: boolean; handledAt?: Date }
>();
const filtered = events
.map((event) => ({
expertId: String(event.expertId),
fileId: String(event.fileId),
eventType: event.eventType,
occurredAt:
event.occurredAt instanceof Date
? event.occurredAt
: new Date(event.occurredAt),
}))
.filter(
(event) =>
!Number.isNaN(event.occurredAt.getTime()) && event.occurredAt <= cutoff,
)
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
for (const event of filtered) {
const key = `${event.expertId}:${event.fileId}`;
const prev = stateByExpertFile.get(key) ?? {
checked: false,
handled: false,
};
if (event.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (event.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (event.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
prev.handledAt = event.occurredAt;
}
stateByExpertFile.set(key, prev);
}
return stateByExpertFile;
}
private emptyExpertCaseBreakdown() {
return {
totalFiles: 0,
reviewedFiles: 0,
unreviewedFiles: 0,
thirdPartyFiles: 0,
carBodyFiles: 0,
thirdPartyReviewedFiles: 0,
thirdPartyUnreviewedFiles: 0,
carBodyReviewedFiles: 0,
carBodyUnreviewedFiles: 0,
};
}
private addFileToExpertCaseBreakdown(
counts: {
totalFiles: number;
reviewedFiles: number;
unreviewedFiles: number;
thirdPartyFiles: number;
carBodyFiles: number;
thirdPartyReviewedFiles: number;
thirdPartyUnreviewedFiles: number;
carBodyReviewedFiles: number;
carBodyUnreviewedFiles: number;
},
fileType: string | undefined,
reviewed: boolean,
) {
counts.totalFiles += 1;
if (reviewed) counts.reviewedFiles += 1;
else counts.unreviewedFiles += 1;
if (fileType === BlameRequestType.CAR_BODY) {
counts.carBodyFiles += 1;
if (reviewed) counts.carBodyReviewedFiles += 1;
else counts.carBodyUnreviewedFiles += 1;
return;
}
counts.thirdPartyFiles += 1;
if (reviewed) counts.thirdPartyReviewedFiles += 1;
else counts.thirdPartyUnreviewedFiles += 1;
}
private async buildExpertReportRows(
actor: any,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
) {
const clientObjectId = this.getClientId(actor);
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const range = this.resolveReportDateRange(opts);
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles, activityEvents] =
await Promise.all([
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
this.fieldExpertDbService.findAll(ckFilter as never),
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
this.expertFileActivityDbService.findByTenant(clientObjectId),
]);
const roster = [
...(experts as any[]).map((expert) => ({
_id: String(expert._id),
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
expertKind: "blame" as const,
role: expert.role,
type: expert.userType,
createdAt: expert.createdAt ?? null,
})),
...(damageExperts as any[]).map((expert) => ({
_id: String(expert._id),
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
expertKind: "claim" as const,
role: expert.role,
type: expert.userType,
createdAt: expert.createdAt ?? null,
})),
...(fieldExperts as any[]).map((expert) => ({
_id: String(expert._id),
fullName: `${expert.firstName} ${expert.lastName}`.trim(),
expertKind: "blame" as const,
role: expert.role,
type: expert.userType,
createdAt: expert.createdAt ?? null,
})),
];
const rosterById = new Map(
roster.map((expert) => [expert._id, { ...expert, ...this.emptyExpertCaseBreakdown() }]),
);
const fileState = this.buildExpertFileStateMap(activityEvents as any[], range.toDate);
const blameFilesInRange = blameFiles.filter((file) =>
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
);
const claimFilesInRange = claimFiles.filter((file) =>
this.isInDateRange(file?.createdAt, range.fromDate, range.toDate),
);
for (const file of blameFilesInRange as any[]) {
const expertIds = this.blameFieldExpertIdCandidates(file).filter((expertId) =>
rosterById.has(expertId),
);
for (const expertId of new Set(expertIds)) {
const row = rosterById.get(expertId);
if (!row) continue;
const state = fileState.get(`${expertId}:${String(file._id)}`);
this.addFileToExpertCaseBreakdown(
row,
String(file?.type ?? BlameRequestType.THIRD_PARTY),
!!state?.handled,
);
}
}
for (const file of claimFilesInRange as any[]) {
const expertIds = [
file?.initiatedByFieldExpertId
? String(file.initiatedByFieldExpertId)
: undefined,
this.claimDamageExpertActorId(file) ?? undefined,
]
.filter((expertId): expertId is string => !!expertId)
.filter((expertId) => rosterById.has(expertId));
const fileType = String(
file?.snapshot?.accident?.type ??
file?.blameFile?.type ??
BlameRequestType.THIRD_PARTY,
);
for (const expertId of new Set(expertIds)) {
const row = rosterById.get(expertId);
if (!row) continue;
const state = fileState.get(`${expertId}:${String(file._id)}`);
this.addFileToExpertCaseBreakdown(row, fileType, !!state?.handled);
}
}
return {
range,
experts: Array.from(rosterById.values()),
};
}
private portfolioFileReviewed(
file: {
blame?: { requestId?: string };
claim?: { requestId?: string };
},
handledAtByFileId: Map<string, Date>,
): boolean {
const blameHandled = file.blame?.requestId
? handledAtByFileId.has(String(file.blame.requestId))
: false;
const claimHandled = file.claim?.requestId
? handledAtByFileId.has(String(file.claim.requestId))
: false;
return blameHandled || claimHandled;
}
private portfolioFileHandledAt(
file: {
blame?: { requestId?: string };
claim?: { requestId?: string };
},
handledAtByFileId: Map<string, Date>,
): Date | null {
const handledDates = [
file.blame?.requestId
? handledAtByFileId.get(String(file.blame.requestId))
: undefined,
file.claim?.requestId
? handledAtByFileId.get(String(file.claim.requestId))
: undefined,
].filter((value): value is Date => value instanceof Date);
if (!handledDates.length) return null;
return new Date(Math.max(...handledDates.map((value) => value.getTime())));
}
async retrieveAllExpertsOfClient(
actor,
currentPage: number,
countPerPage: number,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
) {
const report = await this.buildExpertReportRows(actor, opts);
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
const start = (page - 1) * perPage;
return {
range: this.serializeReportRange(report.range),
total: report.experts.length,
page,
countPerPage: perPage,
experts: report.experts.slice(start, start + perPage),
};
}
async getTopExpertsForClient(
actor,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
): Promise<{
range: {
mode: "preset" | "custom";
from: string;
to: string;
periodDays?: 30 | 60 | 90;
};
experts: any[];
}> {
const report = await this.buildExpertReportRows(actor, opts);
const experts = report.experts
.filter((expert) => expert.totalFiles > 0)
.sort((a, b) => {
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
return a.fullName.localeCompare(b.fullName, "fa");
})
.slice(0, 10)
.map((expert) => ({
_id: expert._id,
fullName: expert.fullName,
expertKind: expert.expertKind,
totalFiles: expert.totalFiles,
reviewedFiles: expert.reviewedFiles,
unreviewedFiles: expert.unreviewedFiles,
thirdPartyFiles: expert.thirdPartyFiles,
carBodyFiles: expert.carBodyFiles,
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
carBodyReviewedFiles: expert.carBodyReviewedFiles,
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
}));
return {
range: this.serializeReportRange(report.range),
experts,
};
}
/**
* Legacy endpoint kept for backward compatibility.
* Returns top 10 claim files for the current insurer client based on
* combined insurer + user ratings.
*/
async getTopFilesForClient(
insurerId: string,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
): Promise<Array<{
publicId: string;
createdAt: string;
combinedScore: number;
userRating: { comment: string | null; overallEvaluation: number | null };
}>> {
const range = this.resolveReportDateRange(opts);
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
claimFiles = claimFiles.filter((file) =>
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
);
return claimFiles
.map((file) => {
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore === null) return null;
const ur = file?.userRating;
return {
publicId: String(file.publicId ?? ""),
createdAt:
file.createdAt instanceof Date
? file.createdAt.toISOString()
: String(file.createdAt ?? ""),
combinedScore,
userRating: {
comment: ur?.comment ?? null,
overallEvaluation:
typeof ur?.overallEvaluation === "number"
? ur.overallEvaluation
: null,
},
};
})
.filter((f): f is NonNullable<typeof f> => f !== null)
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, 10);
}
async getReportCharts(
actor: any,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
) {
const clientObjectId = this.getClientId(actor);
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
this.buildExpertReportRows(actor, opts),
this.buildMergedInsurerFiles(clientObjectId),
this.expertFileActivityDbService.findByTenant(clientObjectId),
]);
const range = expertReport.range;
const handledState = this.buildExpertFileStateMap(
activityEvents as any[],
range.toDate,
);
const handledAtByFileId = new Map<string, Date>();
for (const [key, state] of handledState.entries()) {
if (!state.handled || !state.handledAt) continue;
const fileId = key.split(":").slice(1).join(":");
const prev = handledAtByFileId.get(fileId);
if (!prev || prev < state.handledAt) {
handledAtByFileId.set(fileId, state.handledAt);
}
}
const filesInRange = mergedFiles.filter((file) =>
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
);
const filesPerDayMap = new Map<
string,
{ totalFiles: number; thirdPartyFiles: number; carBodyFiles: number }
>();
const avgHandlingByDayMap = new Map<
string,
{ handledFiles: number; totalDurationMs: number }
>();
for (
let cursor = this.startOfDay(range.fromDate);
cursor <= range.toDate;
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000)
) {
const key = this.toUtcDateKey(cursor);
filesPerDayMap.set(key, {
totalFiles: 0,
thirdPartyFiles: 0,
carBodyFiles: 0,
});
avgHandlingByDayMap.set(key, { handledFiles: 0, totalDurationMs: 0 });
}
for (const file of filesInRange as any[]) {
const createdAt = new Date(file.createdAt);
if (!Number.isNaN(createdAt.getTime())) {
const createdKey = this.toUtcDateKey(createdAt);
const createdBucket = filesPerDayMap.get(createdKey);
if (createdBucket) {
createdBucket.totalFiles += 1;
if (file.fileType === BlameRequestType.CAR_BODY) {
createdBucket.carBodyFiles += 1;
} else {
createdBucket.thirdPartyFiles += 1;
}
}
}
const handledAt = this.portfolioFileHandledAt(file, handledAtByFileId);
if (!handledAt || handledAt < range.fromDate || handledAt > range.toDate) {
continue;
}
if (Number.isNaN(createdAt.getTime())) continue;
const handledKey = this.toUtcDateKey(handledAt);
const handledBucket = avgHandlingByDayMap.get(handledKey);
if (!handledBucket) continue;
handledBucket.handledFiles += 1;
handledBucket.totalDurationMs += Math.max(
handledAt.getTime() - createdAt.getTime(),
0,
);
}
const topExperts = expertReport.experts
.filter((expert) => expert.totalFiles > 0)
.sort((a, b) => {
if (b.totalFiles !== a.totalFiles) return b.totalFiles - a.totalFiles;
return a.fullName.localeCompare(b.fullName, "fa");
})
.slice(0, 10)
.map((expert) => ({
_id: expert._id,
fullName: expert.fullName,
expertKind: expert.expertKind,
totalFiles: expert.totalFiles,
reviewedFiles: expert.reviewedFiles,
unreviewedFiles: expert.unreviewedFiles,
thirdPartyFiles: expert.thirdPartyFiles,
carBodyFiles: expert.carBodyFiles,
thirdPartyReviewedFiles: expert.thirdPartyReviewedFiles,
thirdPartyUnreviewedFiles: expert.thirdPartyUnreviewedFiles,
carBodyReviewedFiles: expert.carBodyReviewedFiles,
carBodyUnreviewedFiles: expert.carBodyUnreviewedFiles,
}));
return {
range: this.serializeReportRange(range),
topExperts,
filesPerExpertThirdParty: expertReport.experts
.filter((expert) => expert.thirdPartyFiles > 0)
.sort((a, b) => b.thirdPartyFiles - a.thirdPartyFiles)
.map((expert) => ({
expertId: expert._id,
fullName: expert.fullName,
expertKind: expert.expertKind,
value: expert.thirdPartyFiles,
})),
filesPerExpertCarBody: expertReport.experts
.filter((expert) => expert.carBodyFiles > 0)
.sort((a, b) => b.carBodyFiles - a.carBodyFiles)
.map((expert) => ({
expertId: expert._id,
fullName: expert.fullName,
expertKind: expert.expertKind,
value: expert.carBodyFiles,
})),
filesByDay: Array.from(filesPerDayMap.entries()).map(([date, value]) => ({
date,
...value,
})),
averageHandlingTimeByDay: Array.from(avgHandlingByDayMap.entries()).map(
([date, value]) => ({
date,
handledFiles: value.handledFiles,
averageHandlingDays:
value.handledFiles > 0
? parseFloat(
(
value.totalDurationMs /
value.handledFiles /
(24 * 60 * 60 * 1000)
).toFixed(2),
)
: 0,
}),
),
};
}
async getAllFilesForInsurerExpert(
expertId: string,
insurerClientKey: string,
) {
const expertObjectId = this.parseObjectId(expertId, "expert id");
const clientOid = this.getClientId(insurerClientKey);
const ckFilter = this.clientKeyScopeFilter(clientOid);
const [experts, damageExperts, fieldExperts] = await Promise.all([
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
this.fieldExpertDbService.findAll(ckFilter as never),
]);
const id = String(expertObjectId);
const onBlameRoster =
experts.some((e) => String(e._id) === id) ||
fieldExperts.some((e) => String(e._id) === id);
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
if (!onBlameRoster && !onClaimRoster) {
throw new ForbiddenException(
"This expert is not registered under your insurance company.",
);
}
// Gather all files this expert touched — blame and claim — regardless of
// which insurance company's party appears on the file. The roster check
// above already ensures the expert belongs to THIS insurer, so showing all
// files they handled is the correct scope.
const [allBlames, allClaims] = await Promise.all([
onBlameRoster
? (this.blameRequestDbService.find({}, { lean: true }) as Promise<any[]>)
: Promise.resolve([] as any[]),
(this.claimCaseDbService.find({}, { lean: true }) as Promise<any[]>),
]);
const result: any[] = [];
// Blame files handled by a field/blame expert
if (onBlameRoster) {
for (const b of allBlames) {
if (this.blameFieldExpertIdCandidates(b).includes(id)) {
result.push(this.mapBlameFileSummaryForInsurerExpert(b));
}
}
}
// Claim files: initiated by this field expert OR evaluated by this damage expert
for (const c of allClaims) {
const isFieldExpertClaim =
c?.initiatedByFieldExpertId &&
String(c.initiatedByFieldExpertId) === id;
const isDamageExpertClaim =
onClaimRoster && this.claimDamageExpertActorId(c) === id;
if (isFieldExpertClaim || isDamageExpertClaim) {
result.push(this.mapClaimFileSummaryForInsurerExpert(c));
}
}
return result;
}
private validateInsurerFileRating(rating: FileRating) {
const required: (keyof FileRating)[] = [
"collisionMethodAccuracy",
"evaluationTimeliness",
"accidentCauseAccuracy",
"guiltyVehicleIdentification",
"botRating",
];
for (const key of required) {
const value = rating?.[key];
if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException(
`${key} must be a number between 0 and 5`,
);
}
}
}
/**
* Applies one insurer rating to every underlying case (claim and/or blame) for
* the shared `publicId`, when that case exists and belongs to this insurer.
*/
async rateExpertByPublicId(
publicId: string,
rating: FileRating,
insurerClientKey: string,
) {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
this.validateInsurerFileRating(rating);
const id = this.getClientId(insurerClientKey);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find(
(b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
const out: {
publicId: string;
claim?: { requestId: string; updatedRating: FileRating };
blame?: { requestId: string; updatedRating: FileRating };
} = { publicId };
if (claim) {
const claimId = this.parseObjectId(
String((claim as any)._id),
"claim id",
);
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
$set: { "evaluation.rating": rating },
});
if (!updated) throw new NotFoundException("Claim file not found");
out.claim = {
requestId: String((updated as any)._id),
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
};
}
if (blame) {
const blameId = this.parseObjectId(
String((blame as any)._id),
"blame id",
);
const updated = await this.blameRequestDbService.findByIdAndUpdate(
blameId,
{
$set: { "expert.rating": rating },
},
);
if (!updated) throw new NotFoundException("Blame file not found");
out.blame = {
requestId: String((updated as any)._id),
updatedRating: (updated as any)?.expert?.rating ?? rating,
};
}
return {
message: "Rating saved for this file",
...out,
};
}
private async buildMergedInsurerFiles(clientObjectId: Types.ObjectId) {
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const byPublicId = new Map<
string,
{
publicId: string;
fileType?: string;
parties?: Array<{
role?: string;
fullName?: string;
vehicle?: { carName?: string; carModel?: string; plate?: string };
}>;
blame?: {
requestId?: string;
requestNo?: string;
status?: string;
parties?: Array<{
role?: string;
fullName?: string;
vehicle?: { carName?: string; carModel?: string; plate?: string };
}>;
expertNames?: string[];
createdAt?: Date | string;
updatedAt?: Date | string;
};
claim?: {
requestId?: string;
requestNo?: string;
status?: string;
claimStatus?: string;
currentStep?: string;
expertNames?: string[];
createdAt?: Date | string;
updatedAt?: Date | string;
};
createdAt?: Date | string;
updatedAt?: Date | string;
}
>();
for (const b of blameFiles) {
const key = String((b as any).publicId || (b as any)._id || "");
if (!key) continue;
const prev = byPublicId.get(key) ?? { publicId: key };
const partiesPreview = this.buildPartiesPreview((b as any).parties);
prev.fileType = prev.fileType ?? (b as any).type;
prev.blame = {
requestId:
(b as any)?._id?.toString?.() || String((b as any)?._id || ""),
requestNo:
(b as any).requestNo || String((b as any).requestNumber || ""),
status: (b as any).status,
parties: partiesPreview,
expertNames: extractExpertNamesFromBlame(b),
createdAt: (b as any).createdAt,
updatedAt: (b as any).updatedAt,
};
prev.parties = prev.parties ?? partiesPreview;
prev.createdAt = prev.createdAt ?? (b as any).createdAt;
prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt;
byPublicId.set(key, prev);
}
const claimOnlyBlameIds = claimFiles
.filter((c) => {
const key = String((c as any).publicId || (c as any)._id || "");
return key && !byPublicId.has(key) && (c as any).blameRequestId;
})
.map((c) => String((c as any).blameRequestId));
const uniqueBlameIds = [...new Set(claimOnlyBlameIds)].filter(Boolean);
const linkedBlames =
uniqueBlameIds.length > 0
? ((await this.blameRequestDbService.find(
{
_id: {
$in: uniqueBlameIds.map((id) => new Types.ObjectId(id)),
},
},
{ lean: true },
)) as any[])
: [];
const linkedBlameByPublicId = new Map<string, any>(
linkedBlames.map((b) => [String(b.publicId), b]),
);
for (const c of claimFiles) {
const key = String((c as any).publicId || (c as any)._id || "");
if (!key) continue;
const prev = byPublicId.get(key) ?? { publicId: key };
if (!prev.blame) {
const linkedBlame = linkedBlameByPublicId.get(key);
if (linkedBlame) {
const partiesFromBlame = this.buildPartiesPreview(
linkedBlame.parties,
);
prev.fileType = prev.fileType ?? linkedBlame.type;
prev.blame = {
requestId: String(linkedBlame._id),
requestNo: linkedBlame.requestNo || "",
status: linkedBlame.status,
parties: partiesFromBlame,
expertNames: extractExpertNamesFromBlame(linkedBlame),
createdAt: linkedBlame.createdAt,
updatedAt: linkedBlame.updatedAt,
};
prev.parties = prev.parties ?? partiesFromBlame;
prev.createdAt = prev.createdAt ?? linkedBlame.createdAt;
}
}
const claimPartiesPreview = this.buildPartiesPreview(
(c as any)?.snapshot?.parties,
);
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
prev.claim = {
requestId:
(c as any)?._id?.toString?.() || String((c as any)?._id || ""),
requestNo:
(c as any).requestNo || String((c as any).requestNumber || ""),
status: (c as any).status,
claimStatus: (c as any).claimStatus,
currentStep: (c as any).currentStep,
expertNames: extractExpertNamesFromClaim(c),
createdAt: (c as any).createdAt,
updatedAt: (c as any).updatedAt,
};
prev.parties = prev.parties ?? claimPartiesPreview;
prev.createdAt = prev.createdAt ?? (c as any).createdAt;
prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt;
byPublicId.set(key, prev);
}
return Array.from(byPublicId.values()).map((f) => ({
publicId: f.publicId,
fileType: f.fileType,
hasClaim: !!f.claim,
unifiedFileStatus: resolveUnifiedFileStatus({
blameStatus: f.blame?.status,
claimStatus: f.claim?.status,
}),
parties: f.parties,
blame: f.blame,
claim: f.claim,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}));
}
async retrieveAllFilesOfClient(
insurerId: string,
query: ListQueryV2Dto = {},
) {
const id = this.getClientId(insurerId);
let files = await this.buildMergedInsurerFiles(id);
if (query.unifiedStatus) {
files = files.filter(
(f) => f.unifiedFileStatus === query.unifiedStatus,
);
}
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
files = files.filter((f) =>
isInListDateRange(f.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2(
files,
{
publicId: (f) => f.publicId,
createdAt: (f) => f.updatedAt ?? f.createdAt,
requestNo: (f) => f.publicId,
status: (f) => f.unifiedFileStatus,
fileType: (f) => f.fileType,
searchExtras: (f) =>
[
f.fileType,
f.blame?.requestNo,
f.claim?.requestNo,
f.blame?.status,
f.claim?.status,
f.unifiedFileStatus,
].filter(Boolean) as string[],
},
query,
);
return {
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
files: paged.list,
};
}
async getInsurerUnifiedFileStatusReport(
actor: any,
query: UnifiedFileStatusReportQueryDto = {},
): Promise<UnifiedFileStatusReportDto> {
const clientObjectId = this.getClientId(actor);
const range = this.resolveReportDateRange({
from: query.from,
to: query.to,
periodDays: query.periodDays,
});
const files = await this.buildMergedInsurerFiles(clientObjectId);
let inRange = files.filter((f) =>
this.isInDateRange(f.createdAt, range.fromDate, range.toDate),
);
if (query.fileType) {
inRange = inRange.filter((f) => f.fileType === query.fileType);
}
return {
statuses: getUnifiedFileStatusCatalog(),
counts: countUnifiedFileStatuses(
inRange.map((f) => ({
blameStatus: f.blame?.status,
claimStatus: f.claim?.status,
})),
),
};
}
async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
const id = this.getClientId(insurerId);
const idStr = String(id);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find(
(b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
let blameFull: Record<string, unknown> | null = null;
if (blame) {
blameFull = (await this.blameRequestDbService.findByIdWithoutHistory(
String((blame as any)._id),
)) as Record<string, unknown> | null;
}
let claimFull: Record<string, unknown> | null = null;
if (claim) {
const rows = (await this.claimCaseDbService.find(
{ _id: new Types.ObjectId(String((claim as any)._id)) },
{ lean: true },
)) as Record<string, unknown>[];
claimFull = rows[0] ?? null;
}
let blameForClaimContext: Record<string, unknown> | null = blameFull;
if (!blameForClaimContext && claimFull?.blameRequestId) {
const linked = (await this.blameRequestDbService.findByIdWithoutHistory(
String(claimFull.blameRequestId),
)) as Record<string, unknown> | null;
if (linked && blameCaseTouchesClient(linked, idStr)) {
blameForClaimContext = linked;
}
}
const overview = {
publicId,
requestNo:
(blame as any)?.requestNo ||
(claim as any)?.requestNo ||
(blame as any)?.requestNumber ||
(claim as any)?.requestNumber,
type: (blame as any)?.type ?? (blameFull as any)?.type,
creationMethod:
(blame as any)?.creationMethod ?? (blameFull as any)?.creationMethod,
hasClaim: !!claim,
blameStatus: (blame as any)?.status ?? (blameFull as any)?.status,
claimStatus: (claim as any)?.status,
claimReviewStatus: (claim as any)?.claimStatus,
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
};
const blameDetails = blameFull
? await this.buildInsurerBlameDetail(blameFull)
: undefined;
const claimDetails = claimFull
? await this.buildInsurerClaimDetail(claimFull, blameForClaimContext)
: undefined;
return {
overview,
blame: blameDetails,
claim: claimDetails,
};
}
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
const clientId = this.getClientId(clientKey);
const existingBranch = await this.branchDbService.findOne({
clientKey: clientId,
code: branchDto.code,
});
if (existingBranch) {
throw new ConflictException(
`A branch with code '${branchDto.code}' already exists for this client.`,
);
}
const newBranch = await this.branchDbService.create({
...branchDto,
isActive: branchDto.isActive ?? true,
activityStartDate: branchDto.activityStartDate
? new Date(branchDto.activityStartDate)
: undefined,
clientKey: clientId,
});
return newBranch;
}
private parseOptionalBoolean(value?: string): boolean | undefined {
if (value == null || value === "") return undefined;
const normalized = String(value).trim().toLowerCase();
if (["true", "1", "yes"].includes(normalized)) return true;
if (["false", "0", "no"].includes(normalized)) return false;
throw new BadRequestException("Invalid boolean value for 'isActive'");
}
private buildHandlingBranchStatusSets() {
const blameInHandling = new Set<string>([
CaseStatus.OPEN,
CaseStatus.WAITING_FOR_SECOND_PARTY,
CaseStatus.WAITING_FOR_EXPERT,
CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
CaseStatus.WAITING_FOR_SIGNATURES,
]);
const claimInHandling = new Set<string>([
ClaimCaseStatus.CREATED,
ClaimCaseStatus.SELECTING_OUTER_PARTS,
ClaimCaseStatus.SELECTING_OTHER_PARTS,
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
ClaimCaseStatus.EXPERT_REVIEWING,
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
]);
return { blameInHandling, claimInHandling };
}
async setBranchActive(
clientKey: string,
branchId: string,
isActive: boolean,
) {
const clientId = this.getClientId(clientKey);
await this.assertBranchBelongsToClient(branchId, clientId);
const updated = await this.branchDbService.findByIdAndUpdate(branchId, {
$set: { isActive },
});
if (!updated) {
throw new NotFoundException("Branch not found");
}
return updated;
}
private async assertBranchBelongsToClient(
branchId: string,
clientId: Types.ObjectId,
) {
const branch = await this.branchDbService.findById(branchId);
if (!branch) {
throw new NotFoundException("Branch not found");
}
if (String(branch.clientKey) !== String(clientId)) {
throw new ForbiddenException(
"Selected branch does not belong to your insurance company.",
);
}
return branch;
}
private sanitizeExpertResponse(doc: any) {
const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc;
if (!obj) return obj;
const { password, otp, ...rest } = obj;
return rest;
}
private normalizeExpertLocations(
locations?: Array<{ id?: string; name?: string }> | null,
): Array<{ id: string; name: string }> | undefined {
if (!Array.isArray(locations) || locations.length === 0) return undefined;
const normalized = locations
.map((entry) => ({
id: String(entry?.id ?? "").trim(),
name: String(entry?.name ?? "").trim(),
}))
.filter((entry) => entry.id && entry.name);
return normalized.length > 0 ? normalized : undefined;
}
private async createExpertForInsurer(
insurerClientKey: string,
payload: CreateInsurerExpertDto,
role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existingByEmail = await this.expertDbService.findOne({ email });
const existingDamageByEmail = await this.damageExpertDbService.findOne({
email,
});
if (existingByEmail || existingDamageByEmail) {
throw new ConflictException("An expert with this email already exists.");
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.expertDbService.findOne({
nationalCode,
});
const existingDamageByNational = await this.damageExpertDbService.findOne({
nationalCode,
});
if (existingByNational || existingDamageByNational) {
throw new ConflictException(
"An expert with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const basePayload: any = {
...payload,
email,
nationalCode,
password: hashedPassword,
role,
userType: payload.userType ?? UserType.LEGAL,
clientKey:
role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
insuActivityCo: String(clientObjectId),
};
const created =
role === RoleEnum.EXPERT
? await this.expertDbService.create(basePayload)
: await this.damageExpertDbService.create(basePayload);
return {
expert: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
async addBlameExpert(
insurerClientKey: string,
payload: CreateBlameExpertByInsurerDto,
) {
return this.createExpertForInsurer(
insurerClientKey,
payload,
RoleEnum.EXPERT,
);
}
async addClaimExpert(
insurerClientKey: string,
payload: CreateClaimExpertByInsurerDto,
) {
return this.createExpertForInsurer(
insurerClientKey,
payload,
RoleEnum.DAMAGE_EXPERT,
);
}
async addFileMaker(
insurerClientKey: string,
payload: CreateFileMakerByInsurerDto,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existing = await this.fileMakerDbService.findOne({ email });
if (existing) {
throw new ConflictException("A FileMaker with this email already exists.");
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.fileMakerDbService.findOne({
nationalCode,
});
if (existingByNational) {
throw new ConflictException(
"A FileMaker with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const locations = this.normalizeExpertLocations(payload.locations);
const created = await this.fileMakerDbService.create({
...payload,
email,
nationalCode,
password: hashedPassword,
role: RoleEnum.FILE_MAKER,
clientKey: clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
...(locations ? { locations } : {}),
});
return {
fileMaker: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
async addFileReviewer(
insurerClientKey: string,
payload: CreateFileReviewerByInsurerDto,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existing = await this.fileReviewerDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A FileReviewer with this email already exists.",
);
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.fileReviewerDbService.findOne({
nationalCode,
});
if (existingByNational) {
throw new ConflictException(
"A FileReviewer with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const locations = this.normalizeExpertLocations(payload.locations);
const created = await this.fileReviewerDbService.create({
...payload,
email,
nationalCode,
password: hashedPassword,
role: RoleEnum.FILE_REVIEWER,
clientKey: clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
...(locations ? { locations } : {}),
});
return {
fileReviewer: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
async getExpertStatisticsReport(
actor: any,
opts: { from?: string; to?: string; periodDays?: string | number } = {},
) {
const clientObjectId = this.getClientId(actor);
const [expertReport, mergedFiles, activityEvents] = await Promise.all([
this.buildExpertReportRows(actor, opts),
this.buildMergedInsurerFiles(clientObjectId),
this.expertFileActivityDbService.findByTenant(clientObjectId),
]);
const range = expertReport.range;
const fileState = this.buildExpertFileStateMap(
activityEvents as any[],
range.toDate,
);
const handledAtByFileId = new Map<string, Date>();
for (const [key, state] of fileState.entries()) {
if (!state.handled || !state.handledAt) continue;
const fileId = key.split(":").slice(1).join(":");
const prev = handledAtByFileId.get(fileId);
if (!prev || prev < state.handledAt) {
handledAtByFileId.set(fileId, state.handledAt);
}
}
const filesInRange = mergedFiles.filter((file) =>
this.isInDateRange(file.createdAt, range.fromDate, range.toDate),
);
const summary = this.emptyExpertCaseBreakdown();
for (const file of filesInRange as any[]) {
this.addFileToExpertCaseBreakdown(
summary,
String(file.fileType ?? BlameRequestType.THIRD_PARTY),
this.portfolioFileReviewed(file, handledAtByFileId),
);
}
return {
range: this.serializeReportRange(range),
thirdPartyFiles: summary.thirdPartyFiles,
carBodyFiles: summary.carBodyFiles,
totalFiles: summary.totalFiles,
activeExperts: expertReport.experts.filter((expert) => expert.totalFiles > 0)
.length,
reviewedFiles: summary.reviewedFiles,
unreviewedFiles: summary.unreviewedFiles,
thirdPartyReviewedFiles: summary.thirdPartyReviewedFiles,
thirdPartyUnreviewedFiles: summary.thirdPartyUnreviewedFiles,
carBodyReviewedFiles: summary.carBodyReviewedFiles,
carBodyUnreviewedFiles: summary.carBodyUnreviewedFiles,
};
}
async retrieveInsuranceBranches(
insuranceId: string,
opts?: {
search?: string;
from?: string;
to?: string;
isActive?: string;
},
) {
const clientObjectId = this.getClientId(insuranceId);
const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to);
const isActiveFilter = this.parseOptionalBoolean(opts?.isActive);
const branches = await this.branchDbService.findAllWithFilters(
String(clientObjectId),
{
search: opts?.search,
from: fromDate,
to: toDate,
isActive: isActiveFilter,
},
);
const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const [experts, damageExperts, fieldExperts, blameFiles, claimFiles] =
await Promise.all([
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
this.fieldExpertDbService.findAll(ckFilter as never),
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const activeExpertsByBranch = new Map<string, Set<string>>();
const expertBranchById = new Map<string, string>();
const claimExpertBranchById = new Map<string, string>();
// blame-expert + field-expert both handle blame/IN_PERSON files
for (const e of [...(experts as any[]), ...(fieldExperts as any[])]) {
const bid = e?.branchId ? String(e.branchId) : "";
if (!bid || !branchIdSet.has(bid)) continue;
const eid = String(e?._id || "");
if (!eid) continue;
expertBranchById.set(eid, bid);
if (!activeExpertsByBranch.has(bid))
activeExpertsByBranch.set(bid, new Set());
activeExpertsByBranch.get(bid)!.add(eid);
}
for (const e of damageExperts as any[]) {
const bid = e?.branchId ? String(e.branchId) : "";
if (!bid || !branchIdSet.has(bid)) continue;
const eid = String(e?._id || "");
if (!eid) continue;
claimExpertBranchById.set(eid, bid);
if (!activeExpertsByBranch.has(bid))
activeExpertsByBranch.set(bid, new Set());
activeExpertsByBranch.get(bid)!.add(eid);
}
const completedByBranch = new Map<string, Set<string>>();
const handlingByBranch = new Map<string, Set<string>>();
const { blameInHandling, claimInHandling } =
this.buildHandlingBranchStatusSets();
const addPublicId = (
map: Map<string, Set<string>>,
branchId: string,
fileKey: string,
) => {
if (!map.has(branchId)) map.set(branchId, new Set());
map.get(branchId)!.add(fileKey);
};
for (const b of blameFiles as any[]) {
// Resolve expert via assignment OR field-expert initiation
const candidateIds = this.blameFieldExpertIdCandidates(b);
const bid = candidateIds
.map((eid) => expertBranchById.get(eid))
.find(Boolean);
if (!bid) continue;
const fileKey = String(b?.publicId || b?._id || "");
if (!fileKey) continue;
const status = String(b?.status || "");
if (status === CaseStatus.COMPLETED)
addPublicId(completedByBranch, bid, fileKey);
if (blameInHandling.has(status))
addPublicId(handlingByBranch, bid, fileKey);
}
for (const c of claimFiles as any[]) {
// Field-expert-initiated claims
const fieldExpertId = c?.initiatedByFieldExpertId
? String(c.initiatedByFieldExpertId)
: null;
const damageExpertId = this.claimDamageExpertActorId(c);
const expertId = fieldExpertId ?? damageExpertId ?? "";
if (!expertId) continue;
const bid =
expertBranchById.get(expertId) ??
claimExpertBranchById.get(expertId) ??
undefined;
if (!bid) continue;
const fileKey = String(c?.publicId || c?._id || "");
if (!fileKey) continue;
const status = String(c?.status || "");
if (status === ClaimCaseStatus.COMPLETED)
addPublicId(completedByBranch, bid, fileKey);
if (claimInHandling.has(status))
addPublicId(handlingByBranch, bid, fileKey);
}
const list = branches.map((branch: any) => {
const bid = String(branch._id);
return {
...branch,
totalActiveExperts: activeExpertsByBranch.get(bid)?.size ?? 0,
totalFinishedFiles: completedByBranch.get(bid)?.size ?? 0,
totalFilesInHandling: handlingByBranch.get(bid)?.size ?? 0,
};
});
return {
total: list.length,
filters: {
search: opts?.search ?? null,
from: fromDate ?? null,
to: toDate ?? null,
isActive: isActiveFilter ?? null,
},
branches: list,
};
}
private parseDateRange(from?: string, to?: string) {
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
if (fromDate && Number.isNaN(fromDate.getTime())) {
throw new BadRequestException("Invalid 'from' date");
}
if (toDate && Number.isNaN(toDate.getTime())) {
throw new BadRequestException("Invalid 'to' date");
}
if (fromDate && toDate && fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return { fromDate, toDate };
}
private isInDateRange(value: any, fromDate?: Date, toDate?: Date): boolean {
if (!fromDate && !toDate) return true;
if (!value) return false;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return false;
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
}
async getInsurerFileStatusCounts(
actor: any,
from?: string,
to?: string,
periodDays?: string | number,
): Promise<{
all: number;
completed: number;
under_review: number;
waiting_for_documents_resend: number;
}> {
const report = await this.getInsurerUnifiedFileStatusReport(actor, {
from,
to,
periodDays,
});
const counts = report.counts;
return {
all: counts.all ?? 0,
completed: counts.COMPLETED ?? 0,
under_review:
(counts.WAITING_FOR_BLAME_EXPERT ?? 0) +
(counts.WAITING_FOR_DAMAGE_EXPERT ?? 0) +
(counts.EXPERT_REVIEWING ?? 0) +
(counts.EXPERT_VALIDATING_FACTORS ?? 0),
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
};
}
private static readonly TIMELINE_PERFORMER_FA_LABELS: Record<string, string> = {
user: "کاربر",
field_expert: "کارشناس میدانی",
damage_expert: "کارشناس خسارت",
file_maker: "پرونده‌ساز",
file_reviewer: "بازبین پرونده",
registrar: "ثبت‌کننده",
call_center: "اپراتور کال‌سنتر",
system: "سیستم",
expert: "کارشناس",
};
/**
* Returns the most specific actor role recorded in history so the frontend can
* distinguish between user/system/back-office/expert actions.
*/
private static resolveTimelinePerformedBy(
actorType: string | undefined,
): string | null {
if (!actorType?.trim()) return null;
return actorType.trim().toLowerCase();
}
private static resolveTimelinePerformedByCategory(
actorType: string | undefined,
): "user" | "expert" | "staff" | "system" | null {
const performedBy = ExpertInsurerService.resolveTimelinePerformedBy(actorType);
if (!performedBy) return null;
if (performedBy === "user") return "user";
if (performedBy === "system") return "system";
if (
performedBy === "field_expert" ||
performedBy === "damage_expert" ||
performedBy === "expert"
) {
return "expert";
}
return "staff";
}
private static resolveTimelinePerformedByFaLabel(
actorType: string | undefined,
): string | null {
const performedBy = ExpertInsurerService.resolveTimelinePerformedBy(actorType);
if (!performedBy) return null;
return (
ExpertInsurerService.TIMELINE_PERFORMER_FA_LABELS[performedBy] ??
performedBy
);
}
private static readonly TIMELINE_PLACEHOLDER_ACTOR_NAMES = new Set([
"user",
"actor",
"expert",
"unknown expert",
"field_expert",
"damage_expert",
"file_maker",
"file_reviewer",
"registrar",
"call_center",
"system",
]);
private static getDisplayNameFromRecord(record: any): string | null {
if (!record) return null;
const fullName =
typeof record.fullName === "string" ? record.fullName.trim() : "";
if (fullName) return fullName;
const firstName =
typeof record.firstName === "string" ? record.firstName.trim() : "";
const lastName =
typeof record.lastName === "string" ? record.lastName.trim() : "";
const combined = [firstName, lastName].filter(Boolean).join(" ").trim();
return combined || null;
}
private static isMeaningfulTimelineActorName(name: string | undefined): boolean {
const trimmed = typeof name === "string" ? name.trim() : "";
if (!trimmed) return false;
return !ExpertInsurerService.TIMELINE_PLACEHOLDER_ACTOR_NAMES.has(
trimmed.toLowerCase(),
);
}
private static resolveBlamePartyName(sourceDoc: any, partyRole?: string): string | null {
const parties = Array.isArray(sourceDoc?.parties) ? sourceDoc.parties : [];
const normalizedPartyRole =
typeof partyRole === "string" ? partyRole.trim().toUpperCase() : "";
const matchedParty = normalizedPartyRole
? parties.find(
(party: any) =>
String(party?.role ?? "").trim().toUpperCase() === normalizedPartyRole,
)
: null;
const matchedName = matchedParty?.person?.fullName?.trim?.();
if (matchedName) return matchedName;
if (parties.length === 1) {
return parties[0]?.person?.fullName?.trim?.() || null;
}
return null;
}
private async resolveTimelineActualActorType(
actor: any,
cache: Map<string, string | null> = new Map(),
): Promise<string | null> {
const rawActorType = ExpertInsurerService.resolveTimelinePerformedBy(
actor?.actorType,
);
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
if (!actorId || !Types.ObjectId.isValid(actorId)) {
return rawActorType;
}
if (cache.has(actorId)) {
return cache.get(actorId) ?? rawActorType ?? null;
}
const objectId = new Types.ObjectId(actorId);
const [fileMaker, fileReviewer, registrar, callCenter, fieldExpert, damageExpert, expert, user] = await Promise.all([
this.fileMakerDbService.findById(actorId),
this.fileReviewerDbService.findById(actorId),
this.registrarDbService.findById(actorId),
this.callCenterAgentDbService.findById(actorId),
this.fieldExpertDbService.findById(actorId),
this.damageExpertDbService.findById(actorId),
this.expertDbService.findOne({ _id: objectId }),
this.userDbService.findOne({ _id: objectId }),
]);
const resolvedActorType = fileMaker
? "file_maker"
: fileReviewer
? "file_reviewer"
: registrar
? "registrar"
: callCenter
? "call_center"
: fieldExpert
? "field_expert"
: damageExpert
? "damage_expert"
: expert
? "expert"
: user
? "user"
: rawActorType;
cache.set(actorId, resolvedActorType ?? null);
return resolvedActorType ?? null;
}
private async resolveTimelineActorNameFromId(
actorType: string,
actorId: string,
): Promise<string | null> {
if (!Types.ObjectId.isValid(actorId)) return null;
const objectId = new Types.ObjectId(actorId);
switch (actorType) {
case "user": {
const user = await this.userDbService.findOne({ _id: objectId });
return ExpertInsurerService.getDisplayNameFromRecord(user);
}
case "field_expert": {
const [fieldExpert, expert] = await Promise.all([
this.fieldExpertDbService.findById(actorId),
this.expertDbService.findOne({ _id: objectId }),
]);
return (
ExpertInsurerService.getDisplayNameFromRecord(fieldExpert) ??
ExpertInsurerService.getDisplayNameFromRecord(expert)
);
}
case "damage_expert": {
const [damageExpert, expert] = await Promise.all([
this.damageExpertDbService.findById(actorId),
this.expertDbService.findOne({ _id: objectId }),
]);
return (
ExpertInsurerService.getDisplayNameFromRecord(damageExpert) ??
ExpertInsurerService.getDisplayNameFromRecord(expert)
);
}
case "file_maker": {
const fileMaker = await this.fileMakerDbService.findById(actorId);
return ExpertInsurerService.getDisplayNameFromRecord(fileMaker);
}
case "file_reviewer": {
const [fileReviewer, expert] = await Promise.all([
this.fileReviewerDbService.findById(actorId),
this.expertDbService.findOne({ _id: objectId }),
]);
return (
ExpertInsurerService.getDisplayNameFromRecord(fileReviewer) ??
ExpertInsurerService.getDisplayNameFromRecord(expert)
);
}
case "registrar": {
const registrar = await this.registrarDbService.findById(actorId);
return ExpertInsurerService.getDisplayNameFromRecord(registrar);
}
case "call_center": {
const agent = await this.callCenterAgentDbService.findById(actorId);
return ExpertInsurerService.getDisplayNameFromRecord(agent);
}
case "expert": {
const expert = await this.expertDbService.findOne({ _id: objectId });
return ExpertInsurerService.getDisplayNameFromRecord(expert);
}
default:
return null;
}
}
private buildTimelineActor(
actor: any,
actorName: string | null,
actorType: string | null,
): { actorId?: string; actorName?: string; actorType?: string } | null {
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
if (!actorType && !actorId && !actorName) return null;
return {
...(actorId ? { actorId } : {}),
...(actorName ? { actorName } : {}),
...(actorType ? { actorType } : {}),
};
}
private async resolveTimelineActorName(
actor: any,
actorType: string | null,
sourceDoc?: any,
event?: any,
cache: Map<string, string | null> = new Map(),
): Promise<string | null> {
const explicitName =
typeof actor?.actorName === "string" ? actor.actorName.trim() : "";
if (ExpertInsurerService.isMeaningfulTimelineActorName(explicitName)) {
return explicitName;
}
if (!actorType) return null;
if (actorType === "system") {
return ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
}
if (actorType === "user") {
const ownerName = sourceDoc?.owner?.fullName?.trim?.();
if (ownerName) return ownerName;
const blamePartyName = ExpertInsurerService.resolveBlamePartyName(
sourceDoc,
event?.metadata?.partyRole,
);
if (blamePartyName) return blamePartyName;
}
const actorId = actor?.actorId ? String(actor.actorId).trim() : "";
if (actorId) {
const cacheKey = `${actorType}:${actorId}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey) ?? null;
}
let resolvedName = await this.resolveTimelineActorNameFromId(
actorType,
actorId,
);
if (!resolvedName && actorType === "user") {
resolvedName =
sourceDoc?.owner?.fullName?.trim?.() ??
ExpertInsurerService.resolveBlamePartyName(
sourceDoc,
event?.metadata?.partyRole,
);
}
if (!resolvedName) {
resolvedName =
ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
}
cache.set(cacheKey, resolvedName ?? null);
return resolvedName ?? null;
}
return ExpertInsurerService.resolveTimelinePerformedByFaLabel(actorType);
}
async getFileTimeline(
insurerId: string,
publicId: string,
): Promise<{ publicId: string; events: object[] }> {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
const id = this.getClientId(insurerId);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find(
(b) => String((b as any).publicId) === publicId,
);
const claim = claimFiles.find(
(c) => String((c as any).publicId) === publicId,
);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
const events: object[] = [];
const actorNameCache = new Map<string, string | null>();
const actorTypeCache = new Map<string, string | null>();
if (blame) {
const blameDoc = await this.blameRequestDbService.findById(
String((blame as any)._id),
);
const blameHistory: any[] = (blameDoc as any)?.history ?? [];
for (const ev of blameHistory) {
const performedBy = await this.resolveTimelineActualActorType(
ev.actor,
actorTypeCache,
);
const actorName = await this.resolveTimelineActorName(
ev.actor,
performedBy,
blameDoc,
ev,
actorNameCache,
);
events.push({
source: "blame",
type: ev.type,
faLabel: getEventFaLabel(ev),
description: getEventFaDescription(ev),
timestamp: ev.timestamp,
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
actorName,
performedBy,
performedByCategory:
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
performedByFaLabel:
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
metadata: localizeTimelineMetadata(ev),
});
}
}
if (claim) {
const claimId = String((claim as any)._id);
const rows = (await this.claimCaseDbService.find(
{ _id: new Types.ObjectId(claimId) },
{ lean: true, select: "history createdAt owner" },
)) as Record<string, unknown>[];
const claimDoc = rows[0] as any;
const claimHistory: any[] = claimDoc?.history ?? [];
for (const ev of claimHistory) {
const performedBy = await this.resolveTimelineActualActorType(
ev.actor,
actorTypeCache,
);
const actorName = await this.resolveTimelineActorName(
ev.actor,
performedBy,
claimDoc,
ev,
actorNameCache,
);
events.push({
source: "claim",
type: ev.type,
faLabel: getEventFaLabel(ev),
description: getEventFaDescription(ev),
timestamp: ev.timestamp,
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
actorName,
performedBy,
performedByCategory:
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
performedByFaLabel:
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
metadata: localizeTimelineMetadata(ev),
});
}
}
events.sort((a: any, b: any) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
});
// Resolve insurer's client name for context if needed — just return raw events
return { publicId, events };
}
}