forked from Yara724/api
2573 lines
86 KiB
TypeScript
2573 lines
86 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 { getEventFaLabel } 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));
|
||
}
|
||
|
||
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 file’s user rating, average those
|
||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||
*/
|
||
async getTopExpertsForClient(
|
||
actor,
|
||
opts: { from?: string; to?: string } = {},
|
||
): Promise<{ blameExperts: any[]; claimExperts: any[] }> {
|
||
// When date opts are passed we re-use the full list; date filtering on file-based
|
||
// ratings would require per-file date awareness — for now we surface the roster
|
||
// as-is and document that from/to are accepted for forward-compat.
|
||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||
const rows = result?.experts || [];
|
||
|
||
const byRatingDesc = (a: any, b: any) =>
|
||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||
|
||
// Return only the fields the spec mandates — keep shape minimal
|
||
const slim = (e: any) => ({
|
||
_id: e._id,
|
||
fullName: e.fullName,
|
||
expertKind: e.expertKind,
|
||
overallAverageRating: e.overallAverageRating ?? null,
|
||
requestStats: e.requestStats ?? { totalHandled: 0, totalChecked: 0 },
|
||
});
|
||
|
||
const blameExperts = rows
|
||
.filter((e) => e.expertKind === "blame")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10)
|
||
.map(slim);
|
||
const claimExperts = rows
|
||
.filter((e) => e.expertKind === "claim")
|
||
.sort(byRatingDesc)
|
||
.slice(0, 10)
|
||
.map(slim);
|
||
|
||
return { blameExperts, claimExperts };
|
||
}
|
||
|
||
/**
|
||
* 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 } = {},
|
||
): Promise<Array<{
|
||
publicId: string;
|
||
createdAt: string;
|
||
combinedScore: number;
|
||
userRating: { comment: string | null; overallEvaluation: number | null };
|
||
}>> {
|
||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||
|
||
let claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||
|
||
if (fromDate || toDate) {
|
||
claimFiles = claimFiles.filter((f) => {
|
||
const d = new Date(f.createdAt);
|
||
if (fromDate && d < fromDate) return false;
|
||
if (toDate && d > toDate) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
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 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 { fromDate, toDate } = parseListDateRange(query.from, query.to);
|
||
|
||
const files = await this.buildMergedInsurerFiles(clientObjectId);
|
||
let inRange = files.filter((f) =>
|
||
isInListDateRange(f.createdAt, fromDate, 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 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 created = await this.fileMakerDbService.create({
|
||
...payload,
|
||
email,
|
||
nationalCode,
|
||
password: hashedPassword,
|
||
role: RoleEnum.FILE_MAKER,
|
||
clientKey: clientObjectId,
|
||
branchId: new Types.ObjectId(payload.branchId),
|
||
});
|
||
|
||
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 created = await this.fileReviewerDbService.create({
|
||
...payload,
|
||
email,
|
||
nationalCode,
|
||
password: hashedPassword,
|
||
role: RoleEnum.FILE_REVIEWER,
|
||
clientKey: clientObjectId,
|
||
branchId: new Types.ObjectId(payload.branchId),
|
||
});
|
||
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
opts: { from?: string; to?: string } = {},
|
||
) {
|
||
const clientObjectId = this.getClientId(actor);
|
||
const [claimFiles, blameFiles, activityEvents] = await Promise.all([
|
||
this.getClientClaimFiles(clientObjectId),
|
||
this.getClientBlameFiles(clientObjectId),
|
||
this.expertFileActivityDbService.findByTenant(clientObjectId),
|
||
]);
|
||
|
||
// Optional date range filter for portfolio counts
|
||
const fromDate = opts.from ? new Date(opts.from) : undefined;
|
||
const toDate = opts.to ? new Date(opts.to) : undefined;
|
||
const inRange = (date: unknown) => {
|
||
if (!fromDate && !toDate) return true;
|
||
if (!date) return false;
|
||
const d = new Date(date as string);
|
||
if (fromDate && d < fromDate) return false;
|
||
if (toDate && d > toDate) return false;
|
||
return true;
|
||
};
|
||
const rangedClaimFiles = claimFiles.filter((f) => inRange(f.createdAt));
|
||
|
||
// Current calendar month
|
||
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);
|
||
const filesThisMonth = claimFiles.filter((f) => {
|
||
const d = new Date(f.createdAt);
|
||
return d >= monthStart && d <= monthEnd;
|
||
});
|
||
|
||
// totalFilesReviewed: distinct claim files with at least one CHECKED activity
|
||
const checkedFileIds = new Set<string>();
|
||
for (const ev of activityEvents) {
|
||
if (ev.eventType === ExpertFileActivityType.CHECKED) {
|
||
checkedFileIds.add(String(ev.fileId));
|
||
}
|
||
}
|
||
// Intersect with this insurer's claim file ids
|
||
const claimFileIds = new Set(claimFiles.map((f) => String(f._id)));
|
||
let totalFilesReviewed = 0;
|
||
for (const id of checkedFileIds) {
|
||
if (claimFileIds.has(id)) totalFilesReviewed++;
|
||
}
|
||
|
||
// inPersonAccompaniedCount: blame files where expertInitiated === true AND creationMethod === IN_PERSON
|
||
const inPersonAccompaniedCount = blameFiles.filter(
|
||
(b) => !!(b as any).expertInitiated && (b as any).creationMethod === "IN_PERSON",
|
||
).length;
|
||
|
||
// Per-file statistics over the (optionally date-ranged) claim portfolio
|
||
let totalInsurerRatings = 0;
|
||
let totalBotRatings = 0;
|
||
let filesWithInsurerRating = 0;
|
||
let filesWithBotRating = 0;
|
||
let filesWithUserRating = 0;
|
||
let filesWithObjection = 0;
|
||
|
||
// averageUserRatingPercentage: mean of (progressSpeed + registrationEase + overallEvaluation) / 5 * 100
|
||
let userRatingDimensionSum = 0;
|
||
let userRatingDimensionCount = 0;
|
||
|
||
for (const file of rangedClaimFiles) {
|
||
const insurerRating = file?.rating;
|
||
const userRating = file?.userRating;
|
||
|
||
if (insurerRating) {
|
||
const insurerValues = [
|
||
insurerRating.collisionMethodAccuracy,
|
||
insurerRating.evaluationTimeliness,
|
||
insurerRating.accidentCauseAccuracy,
|
||
insurerRating.guiltyVehicleIdentification,
|
||
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||
if (insurerValues.length > 0) {
|
||
filesWithInsurerRating++;
|
||
totalInsurerRatings += insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length;
|
||
}
|
||
const botRating = (insurerRating as any)?.botRating;
|
||
if (typeof botRating === "number" && !isNaN(botRating)) {
|
||
filesWithBotRating++;
|
||
totalBotRatings += botRating;
|
||
}
|
||
}
|
||
|
||
if (userRating) {
|
||
filesWithUserRating++;
|
||
const dims = [
|
||
userRating.progressSpeed,
|
||
userRating.registrationEase,
|
||
userRating.overallEvaluation,
|
||
].filter((v): v is number => typeof v === "number" && !isNaN(v));
|
||
if (dims.length > 0) {
|
||
userRatingDimensionSum += dims.reduce((a, b) => a + b, 0) / dims.length;
|
||
userRatingDimensionCount++;
|
||
}
|
||
}
|
||
|
||
if (file?.objection) filesWithObjection++;
|
||
}
|
||
|
||
const totalFiles = rangedClaimFiles.length;
|
||
const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0;
|
||
const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0;
|
||
|
||
// averageUserRatingPercentage: (mean score / 5) * 100; mean score is avg of the three
|
||
// per-file dimension averages across all rated files.
|
||
const averageUserRatingPercentage =
|
||
userRatingDimensionCount > 0
|
||
? parseFloat(((userRatingDimensionSum / userRatingDimensionCount / 5) * 100).toFixed(2))
|
||
: 0;
|
||
|
||
const insurerToBotPercentage =
|
||
averageBotRating > 0 && averageInsurerRating > 0
|
||
? parseFloat(((averageInsurerRating / averageBotRating) * 100).toFixed(2))
|
||
: 0;
|
||
|
||
return {
|
||
// New / corrected KPI fields
|
||
totalFilesReviewed,
|
||
averageUserRatingPercentage,
|
||
inPersonAccompaniedCount,
|
||
// Unchanged
|
||
filesCreatedThisMonth: filesThisMonth.length,
|
||
totalFiles,
|
||
objectionPercentage:
|
||
totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0,
|
||
insurerToBotRatingPercentage: insurerToBotPercentage,
|
||
// Deprecated: percentage of files that HAVE a user rating; not a satisfaction metric
|
||
filesWithUserRatingPercentage:
|
||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||
/** @deprecated use filesWithUserRatingPercentage */
|
||
userRatingPercentage:
|
||
totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0,
|
||
breakdown: {
|
||
filesWithInsurerRating,
|
||
filesWithBotRating,
|
||
filesWithUserRating,
|
||
filesWithObjection,
|
||
averageInsurerRating: parseFloat(averageInsurerRating.toFixed(2)),
|
||
averageBotRating: parseFloat(averageBotRating.toFixed(2)),
|
||
},
|
||
};
|
||
}
|
||
|
||
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 report = await this.getInsurerUnifiedFileStatusReport(actor, {
|
||
from,
|
||
to,
|
||
});
|
||
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),
|
||
timestamp: ev.timestamp,
|
||
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
|
||
actorName,
|
||
performedBy,
|
||
performedByCategory:
|
||
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
|
||
performedByFaLabel:
|
||
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
|
||
metadata: ev.metadata ?? null,
|
||
});
|
||
}
|
||
}
|
||
|
||
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),
|
||
timestamp: ev.timestamp,
|
||
actor: this.buildTimelineActor(ev.actor, actorName, performedBy),
|
||
actorName,
|
||
performedBy,
|
||
performedByCategory:
|
||
ExpertInsurerService.resolveTimelinePerformedByCategory(performedBy),
|
||
performedByFaLabel:
|
||
ExpertInsurerService.resolveTimelinePerformedByFaLabel(performedBy),
|
||
metadata: ev.metadata ?? null,
|
||
});
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|
||
}
|