import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from "@nestjs/common"; import { Types } from "mongoose"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { CreateBlameExpertByInsurerDto, CreateClaimExpertByInsurerDto, CreateInsurerExpertDto, } from "./dto/create-insurer-expert.dto"; import { blameCaseTouchesClient, claimCaseTouchesClient, } from "src/helpers/tenant-scope"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { FileRating } from "src/request-management/entities/schema/request-management.schema"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { 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"; @Injectable() export class ExpertInsurerService { constructor( private readonly expertDbService: ExpertDbService, private readonly damageExpertDbService: DamageExpertDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly blameRequestDbService: BlameRequestDbService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly branchDbService: BranchDbService, private readonly hashService: HashService, ) {} 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(); 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 = {}; 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 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 = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; const idStr = String(clientObjectId); return all .filter((f) => claimCaseTouchesClient(f, idStr)) .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, blameFiles, claimFiles] = await Promise.all([ this.expertDbService.findAll(ckFilter as never), this.damageExpertDbService.findAll(ckFilter as never), this.getClientBlameFiles(clientObjectId), this.getClientClaimFiles(clientObjectId), ]); const allExpertsRaw = [...experts, ...damageExperts]; const expertIds = allExpertsRaw.map((e) => String(e._id)); const expertActivityStatsMap = await this.buildExpertActivityStatsMap( clientObjectId, expertIds, ); const expertTotalRatingsMap: Record = {}; const expertRatingsByCategoryMap: 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")), ]; 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] = await Promise.all([ this.expertDbService.findAll(ckFilter as never), this.damageExpertDbService.findAll(ckFilter as never), ]); const id = String(expertObjectId); const onBlameRoster = experts.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.", ); } const clientKeyStr = String(clientOid); if (onBlameRoster) { const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; return blames .filter( (b) => blameCaseTouchesClient(b, clientKeyStr) && this.blameFieldExpertIdCandidates(b).includes(id), ) .map((b) => this.mapBlameFileSummaryForInsurerExpert(b)); } if (onClaimRoster) { const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; return claims .filter( (c) => claimCaseTouchesClient(c, clientKeyStr) && this.claimDamageExpertActorId(c) === id, ) .map((c) => this.mapClaimFileSummaryForInsurerExpert(c)); } return []; } private validateInsurerFileRating(rating: FileRating) { const required: (keyof FileRating)[] = [ "collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating", ]; for (const key of required) { const value = rating?.[key]; if (typeof value !== "number" || value < 0 || value > 5) { throw new BadRequestException(`${key} must be a number between 0 and 5`); } } } /** * Applies one insurer rating to every underlying case (claim and/or blame) for * the shared `publicId`, when that case exists and belongs to this insurer. */ async rateExpertByPublicId( publicId: string, rating: FileRating, insurerClientKey: string, ) { if (!publicId?.trim()) { throw new BadRequestException("publicId is required"); } this.validateInsurerFileRating(rating); const id = this.getClientId(insurerClientKey); const [blameFiles, claimFiles] = await Promise.all([ this.getClientBlameFiles(id), this.getClientClaimFiles(id), ]); const blame = blameFiles.find((b) => String((b as any).publicId) === publicId); const claim = claimFiles.find((c) => String((c as any).publicId) === publicId); if (!blame && !claim) { throw new NotFoundException("File not found for this publicId"); } const out: { publicId: string; claim?: { requestId: string; updatedRating: FileRating }; blame?: { requestId: string; updatedRating: FileRating }; } = { publicId }; if (claim) { const claimId = this.parseObjectId(String((claim as any)._id), "claim id"); const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, { $set: { "evaluation.rating": rating }, }); if (!updated) throw new NotFoundException("Claim file not found"); out.claim = { requestId: String((updated as any)._id), updatedRating: (updated as any)?.evaluation?.rating ?? rating, }; } if (blame) { const blameId = this.parseObjectId(String((blame as any)._id), "blame id"); const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, { $set: { "expert.rating": rating }, }); if (!updated) throw new NotFoundException("Blame file not found"); out.blame = { requestId: String((updated as any)._id), updatedRating: (updated as any)?.expert?.rating ?? rating, }; } return { message: "Rating saved for this file", ...out, }; } async retrieveAllFilesOfClient(insurerId: string) { const id = this.getClientId(insurerId); const [blameFiles, claimFiles] = await Promise.all([ this.getClientBlameFiles(id), this.getClientClaimFiles(id), ]); const byPublicId = new Map< string, { publicId: string; fileType?: string; parties?: Array<{ role?: string; fullName?: string; vehicle?: { carName?: string; carModel?: string; plate?: string; }; }>; blame?: { requestId?: string; requestNo?: string; status?: string; parties?: Array<{ role?: string; fullName?: string; vehicle?: { carName?: string; carModel?: string; plate?: string; }; }>; createdAt?: Date | string; updatedAt?: Date | string; }; claim?: { requestId?: string; requestNo?: string; status?: string; claimStatus?: string; currentStep?: 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, createdAt: (b as any).createdAt, updatedAt: (b as any).updatedAt, }; prev.parties = prev.parties ?? partiesPreview; prev.createdAt = prev.createdAt ?? (b as any).createdAt; prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt; byPublicId.set(key, prev); } for (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 }; 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, createdAt: (c as any).createdAt, updatedAt: (c as any).updatedAt, }; prev.parties = prev.parties ?? claimPartiesPreview; prev.createdAt = prev.createdAt ?? (c as any).createdAt; prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt; byPublicId.set(key, prev); } const files = Array.from(byPublicId.values()) .map((f) => ({ publicId: f.publicId, fileType: f.fileType, hasClaim: !!f.claim, parties: f.parties, blame: f.blame, claim: f.claim, createdAt: f.createdAt, updatedAt: f.updatedAt, })) .sort((a, b) => { const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; return bt - at; }); return { total: files.length, files, }; } async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) { if (!publicId?.trim()) { throw new BadRequestException("publicId is required"); } const id = this.getClientId(insurerId); const [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 parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : []; const firstParty = parties.find((p: any) => p?.role === "FIRST"); const secondParty = parties.find((p: any) => p?.role === "SECOND"); const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties) ? (claim as any).snapshot.parties : undefined; 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, hasClaim: !!claim, blameStatus: (blame 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 = blame ? { requestId: (blame as any)?._id?.toString?.() || String((blame as any)?._id || ""), requestNo: (blame as any).requestNo || String((blame as any).requestNumber || ""), status: (blame as any).status, blameStatus: (blame as any).blameStatus, workflow: (blame as any).workflow, parties: { first: firstParty ? { fullName: firstParty?.person?.fullName, phoneNumber: firstParty?.person?.phoneNumber, role: firstParty?.role, vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle), } : undefined, second: secondParty ? { fullName: secondParty?.person?.fullName, phoneNumber: secondParty?.person?.phoneNumber, role: secondParty?.role, vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle), } : undefined, }, expert: (blame as any).expert ? { assignedExpertId: (blame as any).expert?.assignedExpertId, decision: (blame as any).expert?.decision, resend: (blame as any).expert?.resend, rating: (blame as any).expert?.rating, } : undefined, createdAt: (blame as any).createdAt, updatedAt: (blame as any).updatedAt, } : undefined; // Keep claim section claim-specific to avoid duplicating shared data already in blame/overview. const claimDetails = claim ? { requestId: (claim as any)?._id?.toString?.() || String((claim as any)?._id || ""), requestNo: (claim as any).requestNo || String((claim as any).requestNumber || ""), status: (claim as any).status, claimStatus: (claim as any).claimStatus, owner: (claim as any).owner, vehicle: (claim as any).vehicle, money: (claim as any).money, damage: (claim as any).damage, media: (claim as any).media, requiredDocuments: (claim as any).requiredDocuments, snapshot: (claim as any).snapshot ? { ...(claim as any).snapshot, parties: claimSnapshotParties?.map((p: any) => this.sanitizePartyForInsurerView(p), ), } : undefined, workflow: { currentStep: (claim as any).currentStep, nextStep: (claim as any).nextStep, }, evaluation: { damageExpertReply: (claim as any).damageExpertReply, damageExpertReplyFinal: (claim as any).damageExpertReplyFinal, damageExpertResend: (claim as any).damageExpertResend, objection: (claim as any).objection, priceDrop: (claim as any).priceDrop, visitLocation: (claim as any).visitLocation, factors: (claim as any).evaluation?.factors, }, userResendDocuments: (claim as any).userResendDocuments, userRating: (claim as any).userRating, insurerRating: (claim as any).rating, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, } : 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, clientKey: clientId, }); return newBranch; } private async assertBranchBelongsToClient( branchId: string, clientId: Types.ObjectId, ) { const branch = await this.branchDbService.findById(branchId); if (!branch) { throw new NotFoundException("Branch not found"); } if (String(branch.clientKey) !== String(clientId)) { throw new ForbiddenException( "Selected branch does not belong to your insurance company.", ); } return branch; } private sanitizeExpertResponse(doc: any) { const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc; if (!obj) return obj; const { password, otp, ...rest } = obj; return rest; } private async createExpertForInsurer( insurerClientKey: string, payload: CreateInsurerExpertDto, role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT, ) { const clientObjectId = this.getClientId(insurerClientKey); const branch = await this.assertBranchBelongsToClient( payload.branchId, clientObjectId, ); const email = payload.email.trim().toLowerCase(); const existingByEmail = await this.expertDbService.findOne({ email }); const existingDamageByEmail = await this.damageExpertDbService.findOne({ email }); if (existingByEmail || existingDamageByEmail) { throw new ConflictException("An expert with this email already exists."); } const nationalCode = payload.nationalCode.trim(); const existingByNational = await this.expertDbService.findOne({ nationalCode }); const existingDamageByNational = await this.damageExpertDbService.findOne({ nationalCode, }); if (existingByNational || existingDamageByNational) { throw new ConflictException( "An expert with this national code already exists.", ); } const hashedPassword = await this.hashService.hash(payload.password); const basePayload: any = { ...payload, email, nationalCode, password: hashedPassword, role, userType: payload.userType ?? UserType.LEGAL, clientKey: role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId, branchId: new Types.ObjectId(payload.branchId), insuActivityCo: String(clientObjectId), }; const created = role === RoleEnum.EXPERT ? await this.expertDbService.create(basePayload) : await this.damageExpertDbService.create(basePayload); return { expert: this.sanitizeExpertResponse(created), branch: { _id: (branch as any)._id, name: branch.name, code: branch.code, city: branch.city, state: branch.state, address: branch.address, }, }; } async addBlameExpert( insurerClientKey: string, payload: CreateBlameExpertByInsurerDto, ) { return this.createExpertForInsurer( insurerClientKey, payload, RoleEnum.EXPERT, ); } async addClaimExpert( insurerClientKey: string, payload: CreateClaimExpertByInsurerDto, ) { return this.createExpertForInsurer( insurerClientKey, payload, RoleEnum.DAMAGE_EXPERT, ); } /** * Get comprehensive statistics for all experts of a client * Returns: * - Percentage of insurer's rating to BOT * - Percentage of expert's rating given by users * - Percentage of files that have objection * - Number of files created in the current month */ async getExpertStatisticsReport(actor: any) { const clientObjectId = this.getClientId(actor); const claimFiles = await this.getClientClaimFiles(clientObjectId); // Calculate current month date range const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); // Filter files created this month const filesThisMonth = claimFiles.filter((file) => { const createdAt = new Date(file.createdAt); return createdAt >= monthStart && createdAt <= monthEnd; }); // Calculate statistics let totalInsurerRatings = 0; let totalBotRatings = 0; let filesWithInsurerRating = 0; let filesWithBotRating = 0; let filesWithUserRating = 0; let filesWithObjection = 0; for (const file of claimFiles) { const insurerRating = file?.rating; const userRating = file?.userRating; const objection = file?.objection; // Check for insurer rating (excluding botRating) if (insurerRating) { const insurerValues = [ insurerRating.collisionMethodAccuracy, insurerRating.evaluationTimeliness, insurerRating.accidentCauseAccuracy, insurerRating.guiltyVehicleIdentification, ].filter((val): val is number => typeof val === "number" && !isNaN(val)); if (insurerValues.length > 0) { filesWithInsurerRating++; const insurerAvg = insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length; totalInsurerRatings += insurerAvg; } } // Check for bot rating (if botRating field exists in rating object) const botRating = (insurerRating as any)?.botRating; if (botRating !== undefined && !isNaN(botRating) && typeof botRating === "number") { filesWithBotRating++; totalBotRatings += botRating; } // Check for user rating if (userRating) { filesWithUserRating++; } // Check for objection if (objection) { filesWithObjection++; } } // Calculate percentages const totalFiles = claimFiles.length; // Calculate average insurer rating (excluding botRating) and average bot rating const averageInsurerRating = filesWithInsurerRating > 0 ? totalInsurerRatings / filesWithInsurerRating : 0; const averageBotRating = filesWithBotRating > 0 ? totalBotRatings / filesWithBotRating : 0; // Calculate percentage: (averageInsurerRating / averageBotRating) * 100 // This shows what percentage the insurer rating is compared to bot rating const insurerToBotPercentage = averageBotRating > 0 && averageInsurerRating > 0 ? parseFloat( ((averageInsurerRating / averageBotRating) * 100).toFixed(2), ) : 0; const userRatingPercentage = totalFiles > 0 ? parseFloat(((filesWithUserRating / totalFiles) * 100).toFixed(2)) : 0; const objectionPercentage = totalFiles > 0 ? parseFloat(((filesWithObjection / totalFiles) * 100).toFixed(2)) : 0; return { insurerToBotRatingPercentage: insurerToBotPercentage, userRatingPercentage: userRatingPercentage, objectionPercentage: objectionPercentage, filesCreatedThisMonth: filesThisMonth.length, totalFiles: totalFiles, breakdown: { filesWithInsurerRating, filesWithBotRating, filesWithUserRating, filesWithObjection, averageInsurerRating: filesWithInsurerRating > 0 ? parseFloat( (totalInsurerRatings / filesWithInsurerRating).toFixed(2), ) : 0, averageBotRating: filesWithBotRating > 0 ? parseFloat((totalBotRatings / filesWithBotRating).toFixed(2)) : 0, }, }; } async retrieveInsuranceBranches(insuranceId: string) { return this.branchDbService.findAll(insuranceId); } private parseDateRange(from?: string, to?: string) { const fromDate = from ? new Date(from) : undefined; const toDate = to ? new Date(to) : undefined; if (fromDate && Number.isNaN(fromDate.getTime())) { throw new BadRequestException("Invalid 'from' date"); } if (toDate && Number.isNaN(toDate.getTime())) { throw new BadRequestException("Invalid 'to' date"); } if (fromDate && toDate && fromDate > toDate) { throw new BadRequestException("'from' must be before or equal to 'to'"); } return { fromDate, toDate }; } private isInDateRange(value: any, fromDate?: Date, toDate?: Date): boolean { if (!fromDate && !toDate) return true; if (!value) return false; const d = new Date(value); if (Number.isNaN(d.getTime())) return false; if (fromDate && d < fromDate) return false; if (toDate && d > toDate) return false; return true; } async getInsurerFileStatusCounts( actor: any, from?: string, to?: string, ): Promise<{ all: number; completed: number; under_review: number; waiting_for_documents_resend: number; }> { const clientObjectId = this.getClientId(actor); const { fromDate, toDate } = this.parseDateRange(from, to); const [blameFilesRaw, claimFilesRaw] = await Promise.all([ this.getClientBlameFiles(clientObjectId), this.getClientClaimFiles(clientObjectId), ]); const blameFiles = blameFilesRaw.filter((f) => this.isInDateRange((f as any)?.createdAt, fromDate, toDate), ); const claimFiles = claimFilesRaw.filter((f) => this.isInDateRange((f as any)?.createdAt, fromDate, toDate), ); const fileMap = new Map< string, { blame?: any; claim?: any } >(); for (const b of blameFiles) { const key = String((b as any).publicId || (b as any)._id || ""); if (!key) continue; const prev = fileMap.get(key) ?? {}; prev.blame = b; fileMap.set(key, prev); } for (const c of claimFiles) { const key = String((c as any).publicId || (c as any)._id || ""); if (!key) continue; const prev = fileMap.get(key) ?? {}; prev.claim = c; fileMap.set(key, prev); } let completed = 0; let underReview = 0; let waitingForDocumentsResend = 0; for (const entry of fileMap.values()) { const blameStatus = entry.blame?.status; const claimStatus = entry.claim?.status; if (claimStatus === ClaimCaseStatus.COMPLETED) { completed += 1; } const blameUnderReview = blameStatus === CaseStatus.WAITING_FOR_EXPERT; const claimUnderReview = claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claimStatus === ClaimCaseStatus.EXPERT_REVIEWING; if (blameUnderReview || claimUnderReview) { underReview += 1; } const blameResend = blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND; const claimResend = claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND; if (blameResend || claimResend) { waitingForDocumentsResend += 1; } } return { all: fileMap.size, completed, under_review: underReview, waiting_for_documents_resend: waitingForDocumentsResend, }; } }