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

1960 lines
65 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.
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,
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 { 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 {
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 { 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,
) {}
/**
* 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));
}
async retrieveAllExpertsOfClient(
actor,
currentPage: number,
countPerPage: number,
) {
const clientObjectId = this.getClientId(actor);
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 allExpertsRaw = [...experts, ...damageExperts, ...fieldExperts];
const expertIds = allExpertsRaw.map((e) => String(e._id));
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
clientObjectId,
expertIds,
);
const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<
string,
Record<string, number[]>
> = {};
const processRatings = (expertId: string | undefined, file: any) => {
if (!expertId) return;
const rating = file?.rating;
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore !== null) {
if (!expertTotalRatingsMap[expertId])
expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId].push(combinedScore);
}
if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId])
expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) {
if (category === "botRating") continue;
if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) {
expertRatingsByCategoryMap[expertId][category] = [];
}
expertRatingsByCategoryMap[expertId][category].push(value);
}
}
};
for (const file of blameFiles) {
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
}
for (const file of claimFiles) {
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
}
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length
? parseFloat(
(
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
).toFixed(2),
)
: null;
const averageRatingsByCategory: Record<string, number> = {};
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
if (ratingsByCat) {
for (const [category, values] of Object.entries(ratingsByCat)) {
averageRatingsByCategory[category] = parseFloat(
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
);
}
}
return {
_id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`,
role: expert.role,
expertKind,
type: expert.userType,
requestStats: expertActivityStatsMap[expertIdStr] ?? {
totalHandled: 0,
totalChecked: 0,
},
createdAt: expert.createdAt,
overallAverageRating,
averageRatingsByCategory,
};
};
const allExperts = [
...experts.map((e) => mapExpertRow(e, "blame")),
...damageExperts.map((e) => mapExpertRow(e, "claim")),
...fieldExperts.map((e) => mapExpertRow(e, "blame")),
];
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
const start = (page - 1) * perPage;
return {
total: allExperts.length,
page,
countPerPage: perPage,
experts: allExperts.slice(start, start + perPage),
};
}
/**
* Top experts per roster: blame rows come from the `expert` collection (files they
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
* Scores are aggregated from **file documents** that insurer can see: for each such
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
* excluding `botRating`), optionally blend with the files user rating, average those
* combined scores per file, then average across that experts files (`overallAverageRating`).
*/
async getTopExpertsForClient(actor): Promise<{
blameExperts: any[];
claimExperts: any[];
}> {
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
const rows = result?.experts || [];
const byRatingDesc = (a: any, b: any) =>
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
const blameExperts = rows
.filter((e) => e.expertKind === "blame")
.sort(byRatingDesc)
.slice(0, 10);
const claimExperts = rows
.filter((e) => e.expertKind === "claim")
.sort(byRatingDesc)
.slice(0, 10);
return { blameExperts, claimExperts };
}
/**
* Returns top 10 claim files for the current insurer client based on
* combined insurer + user ratings.
*/
async getTopFilesForClient(insurerId: string): Promise<any[]> {
const claimFiles = await this.getClientClaimFiles(
this.getClientId(insurerId),
);
const scored = claimFiles
.map((file) => {
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore === null) return null;
return { ...file, combinedScore };
})
.filter((f) => f !== null);
return scored
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, 10);
}
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,
};
}
async retrieveAllFilesOfClient(insurerId: string) {
const id = this.getClientId(insurerId);
const idStr = String(id);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
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;
}
>();
// Index blame files first
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);
}
// For claim files whose blame wasn't in blameFiles (different insurer's blame),
// fetch the linked blame directly so we can get vehicle + parties data
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]),
);
// Index claim files
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 no blame entry yet, use the linked blame for parties/vehicle/fileType
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);
}
const files = Array.from(byPublicId.values())
.map((f) => ({
publicId: f.publicId,
fileType: f.fileType,
hasClaim: !!f.claim,
parties: f.parties,
blame: f.blame,
claim: f.claim,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}))
.sort((a, b) => {
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bt - at;
});
return {
total: files.length,
files,
};
}
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,
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_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 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,
);
}
/**
* Get comprehensive statistics for all experts of a client
* Returns:
* - Percentage of insurer's rating to BOT
* - Percentage of expert's rating given by users
* - Percentage of files that have objection
* - Number of files created in the current month
*/
async getExpertStatisticsReport(actor: any) {
const clientObjectId = this.getClientId(actor);
const claimFiles = await this.getClientClaimFiles(clientObjectId);
// Calculate current month date range
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
23,
59,
59,
);
// Filter files created this month
const filesThisMonth = claimFiles.filter((file) => {
const createdAt = new Date(file.createdAt);
return createdAt >= monthStart && createdAt <= monthEnd;
});
// Calculate statistics
let totalInsurerRatings = 0;
let totalBotRatings = 0;
let filesWithInsurerRating = 0;
let filesWithBotRating = 0;
let filesWithUserRating = 0;
let filesWithObjection = 0;
for (const file of claimFiles) {
const insurerRating = file?.rating;
const userRating = file?.userRating;
const objection = file?.objection;
// Check for insurer rating (excluding botRating)
if (insurerRating) {
const insurerValues = [
insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
].filter(
(val): val is number => typeof val === "number" && !isNaN(val),
);
if (insurerValues.length > 0) {
filesWithInsurerRating++;
const insurerAvg =
insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
totalInsurerRatings += insurerAvg;
}
}
// Check for bot rating (if botRating field exists in rating object)
const botRating = (insurerRating as any)?.botRating;
if (
botRating !== undefined &&
!isNaN(botRating) &&
typeof botRating === "number"
) {
filesWithBotRating++;
totalBotRatings += botRating;
}
// Check for user rating
if (userRating) {
filesWithUserRating++;
}
// Check for objection
if (objection) {
filesWithObjection++;
}
}
// Calculate percentages
const totalFiles = claimFiles.length;
// Calculate average insurer rating (excluding botRating) and average bot rating
const averageInsurerRating =
filesWithInsurerRating > 0
? totalInsurerRatings / filesWithInsurerRating
: 0;
const averageBotRating =
filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
// Calculate percentage: (averageInsurerRating / averageBotRating) * 100
// This shows what percentage the insurer rating is compared to bot rating
const insurerToBotPercentage =
averageBotRating > 0 && averageInsurerRating > 0
? parseFloat(
((averageInsurerRating / averageBotRating) * 100).toFixed(2),
)
: 0;
const userRatingPercentage =
totalFiles > 0
? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2))
: 0;
const objectionPercentage =
totalFiles > 0
? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2))
: 0;
return {
insurerToBotRatingPercentage: insurerToBotPercentage,
userRatingPercentage: userRatingPercentage,
objectionPercentage: objectionPercentage,
filesCreatedThisMonth: filesThisMonth.length,
totalFiles: totalFiles,
breakdown: {
filesWithInsurerRating,
filesWithBotRating,
filesWithUserRating,
filesWithObjection,
averageInsurerRating:
filesWithInsurerRating > 0
? parseFloat(
(totalInsurerRatings / filesWithInsurerRating).toFixed(2),
)
: 0,
averageBotRating:
filesWithBotRating > 0
? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2))
: 0,
},
};
}
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,
): Promise<{
all: number;
completed: number;
under_review: number;
waiting_for_documents_resend: number;
}> {
const clientObjectId = this.getClientId(actor);
const { fromDate, toDate } = this.parseDateRange(from, to);
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const blameFiles = blameFilesRaw.filter((f) =>
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
);
const claimFiles = claimFilesRaw.filter((f) =>
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
);
const fileMap = new Map<string, { blame?: any; claim?: any }>();
for (const b of blameFiles) {
const key = String((b as any).publicId || (b as any)._id || "");
if (!key) continue;
const prev = fileMap.get(key) ?? {};
prev.blame = b;
fileMap.set(key, prev);
}
for (const c of claimFiles) {
const key = String((c as any).publicId || (c as any)._id || "");
if (!key) continue;
const prev = fileMap.get(key) ?? {};
prev.claim = c;
fileMap.set(key, prev);
}
let completed = 0;
let underReview = 0;
let waitingForDocumentsResend = 0;
for (const entry of fileMap.values()) {
const blameStatus = entry.blame?.status;
const claimStatus = entry.claim?.status;
if (claimStatus === ClaimCaseStatus.COMPLETED) {
completed += 1;
}
const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT;
const claimUnderReview =
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
if (blameUnderReview || claimUnderReview) {
underReview += 1;
}
const blameResend =
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
const claimResend =
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
if (blameResend || claimResend) {
waitingForDocumentsResend += 1;
}
}
return {
all: fileMap.size,
completed,
under_review: underReview,
waiting_for_documents_resend: waitingForDocumentsResend,
};
}
}