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 { 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, ) {} /** * 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> { 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( [...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> { 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(); 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 | undefined { if (!decision || typeof decision !== "object") { return undefined; } const d = decision as Record; 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 | 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, ): Promise> { const unique = [ ...new Set( ids .filter((x): x is string => !!x && Types.ObjectId.isValid(String(x))) .map(String), ), ]; const out: Record = {}; 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, ) { 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, ) { 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 | undefined, ): Record | 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), }; } } } 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) }; 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; const nextV = Object.keys(rest).length ? rest : undefined; return { ...p, vehicle: nextV }; }); } private async claimSignLinkFromId( signDetailId: unknown, ): Promise { 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 | undefined, ): Promise | 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 | 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 { if (!Array.isArray(parties)) return; for (const party of parties) { if (!party?.evidence) continue; const evidence = party.evidence as Record; 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, ): Promise> { const doc: Record = { ...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) }; 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, blameForContext: Record | null, ): Promise> { // 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 = {}; 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 = {}; 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 | 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 { 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 { const [all, rosterExpertIds] = await Promise.all([ this.claimCaseDbService.find({}, { lean: true }) as Promise, 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 = {}; const expertRatingsByCategoryMap: Record< string, Record > = {}; 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 = {}; 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): 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 { 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) : Promise.resolve([] as any[]), (this.claimCaseDbService.find({}, { lean: true }) as Promise), ]); 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( 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 { 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 | null = null; if (blame) { blameFull = (await this.blameRequestDbService.findByIdWithoutHistory( String((blame as any)._id), )) as Record | null; } let claimFull: Record | null = null; if (claim) { const rows = (await this.claimCaseDbService.find( { _id: new Types.ObjectId(String((claim as any)._id)) }, { lean: true }, )) as Record[]; claimFull = rows[0] ?? null; } let blameForClaimContext: Record | null = blameFull; if (!blameForClaimContext && claimFull?.blameRequestId) { const linked = (await this.blameRequestDbService.findByIdWithoutHistory( String(claimFull.blameRequestId), )) as Record | 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([ CaseStatus.OPEN, CaseStatus.WAITING_FOR_SECOND_PARTY, CaseStatus.WAITING_FOR_EXPERT, CaseStatus.WAITING_FOR_DOCUMENT_RESEND, CaseStatus.WAITING_FOR_SIGNATURES, ]); const claimInHandling = new Set([ 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) { 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>(); const expertBranchById = new Map(); const claimExpertBranchById = new Map(); // 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>(); const handlingByBranch = new Map>(); const { blameInHandling, claimInHandling } = this.buildHandlingBranchStatusSets(); const addPublicId = ( map: Map>, 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, }; } 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[] = []; 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) { events.push({ source: "blame", type: ev.type, faLabel: getEventFaLabel(ev), timestamp: ev.timestamp, actor: ev.actor ?? null, 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" }, )) as Record[]; const claimHistory: any[] = (rows[0] as any)?.history ?? []; for (const ev of claimHistory) { events.push({ source: "claim", type: ev.type, faLabel: getEventFaLabel(ev), timestamp: ev.timestamp, actor: ev.actor ?? null, 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 }; } }