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 { 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); } 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 }, }, }; } private getCombinedFileScore(file: any): number | null { const insurerRating = file?.rating; const userRating = file?.userRating; const insurerValues = insurerRating ? Object.values(insurerRating).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), ) .filter( (f) => f?.status !== CaseStatus.OPEN && f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY, ) .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) => String(f?.owner?.userClientKey || "") === idStr) .map((f) => this.normalizeClaimCase(f)); } async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) { const clientObjectId = this.getClientId(actor); const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([ this.expertDbService.findAll({ clientKey: String(clientObjectId) }), this.damageExpertDbService.findAll({ clientKey: clientObjectId }), 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 (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 allExperts = allExpertsRaw.map((expert) => { 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, type: expert.userType, requestStats: expertActivityStatsMap[expertIdStr] ?? { totalHandled: 0, totalChecked: 0, }, createdAt: expert.createdAt, overallAverageRating, averageRatingsByCategory, }; }); 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), }; } /** * Returns top 10 experts for the current insurer client based on * combined insurer + user ratings on their handled files. */ async getTopExpertsForClient(actor): Promise { const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000); const experts = result?.experts || []; const sorted = [...experts].sort((a, b) => { const ar = a.overallAverageRating ?? 0; const br = b.overallAverageRating ?? 0; return br - ar; }); return sorted.slice(0, 10); } /** * 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); } private async assertExpertBelongsToInsurer( expertObjectId: Types.ObjectId, insurerClientKey: string, ) { const ck = String(insurerClientKey); const clientOid = new Types.ObjectId(ck); const [experts, damageExperts] = await Promise.all([ this.expertDbService.findAll({ clientKey: ck }), this.damageExpertDbService.findAll({ clientKey: clientOid }), ]); const id = String(expertObjectId); const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id); if (!ok) { throw new ForbiddenException( "This expert is not registered under your insurance company.", ); } } async getAllFilesByExpertAndRole( expertId: string, role: "claim" | "blame", insurerClientKey: string, ) { const expertObjectId = this.parseObjectId(expertId, "expert id"); await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey); const clientKeyStr = String(this.getClientId(insurerClientKey)); if (role === "claim") { const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; return claims .filter( (c) => claimCaseTouchesClient(c, clientKeyStr) && c?.evaluation?.damageExpertReply?.actorDetail?.actorId === String(expertObjectId), ) .map((c) => this.normalizeClaimCase(c)); } const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; return blames .filter( (b) => blameCaseTouchesClient(b, clientKeyStr) && String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId), ) .map((b) => this.normalizeBlameCase(b)); } async rateExpertOnFile( requestId: string, rating: FileRating, role: "claim" | "blame", insurerClientKey: string, ) { const _id = this.parseObjectId(requestId, "request id"); const clientKeyStr = String(this.getClientId(insurerClientKey)); for (const [key, value] of Object.entries(rating || {})) { if (typeof value !== "number" || value < 0 || value > 5) { throw new BadRequestException(`${key} must be a number between 0 and 5`); } } if (role === "claim") { const existing = await this.claimCaseDbService.findById(_id); if (!existing) throw new NotFoundException("Claim file not found"); if (!claimCaseTouchesClient(existing as any, clientKeyStr)) { throw new ForbiddenException("This claim does not belong to your organization."); } const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, { $set: { "evaluation.rating": rating }, }); if (!updated) throw new NotFoundException("Claim file not found"); return { message: "Claim expert rated successfully", updatedRating: (updated as any)?.evaluation?.rating || rating, requestId: updated._id, }; } if (role === "blame") { const existing = await this.blameRequestDbService.findById(_id); if (!existing) throw new NotFoundException("Blame file not found"); if (!blameCaseTouchesClient(existing as any, clientKeyStr)) { throw new ForbiddenException("This blame case does not belong to your organization."); } const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, { $set: { "expert.rating": rating }, }); if (!updated) throw new NotFoundException("Blame file not found"); return { message: "Blame expert rated successfully", updatedRating: (updated as any)?.expert?.rating || rating, requestId: updated._id, }; } throw new BadRequestException("Invalid role"); } async retrieveAllFilesOfClient(insurerId: string) { const id = this.getClientId(insurerId); const [blameFiles, claimFiles] = await Promise.all([ this.getClientBlameFiles(id), this.getClientClaimFiles(id), ]); return { blameFiles, claimFiles }; } 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); } }