Files
yara724api/src/expert-insurer/expert-insurer.service.ts
2026-04-30 10:32:53 +03:30

1151 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateInsurerExpertDto,
} from "./dto/create-insurer-expert.dto";
import {
blameCaseTouchesClient,
claimCaseTouchesClient,
} from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { 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<Record<string, { totalHandled: number; totalChecked: number }>> {
const events = await this.expertFileActivityDbService.findByTenantAndExperts(
tenantId,
expertIds,
);
const stateByExpertFile = new Map<string, { checked: boolean; handled: boolean }>();
for (const e of events) {
const key = `${String(e.expertId)}:${String(e.fileId)}`;
const prev = stateByExpertFile.get(key) ?? { checked: false, handled: false };
if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
}
stateByExpertFile.set(key, prev);
}
const result: Record<string, { totalHandled: number; totalChecked: number }> = {};
for (const expertId of expertIds) {
result[expertId] = { totalHandled: 0, totalChecked: 0 };
}
for (const [key, st] of stateByExpertFile.entries()) {
const [expertId] = key.split(":");
if (!result[expertId]) result[expertId] = { totalHandled: 0, totalChecked: 0 };
if (st.handled) result[expertId].totalHandled += 1;
else if (st.checked) result[expertId].totalChecked += 1;
}
return result;
}
private parseObjectId(value: string, label: string): Types.ObjectId {
if (!Types.ObjectId.isValid(value)) {
throw new BadRequestException(`Invalid ${label}`);
}
return new Types.ObjectId(value);
}
private normalizeClaimCase(claim: any): any {
return {
...claim,
requestNumber: claim.requestNo,
userClientKey: claim.owner?.userClientKey,
fullName: claim.owner?.fullName,
carDetail: claim.vehicle,
carPlate: claim.vehicle?.plate,
claimStatus: claim.status,
rating: claim.evaluation?.rating,
userRating: claim.evaluation?.userRating,
damageExpertReply: claim.evaluation?.damageExpertReply,
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
damageExpertResend: claim.evaluation?.damageExpertResend,
objection: claim.evaluation?.objection,
userResendDocuments: claim.evaluation?.userResendDocuments,
priceDrop: claim.evaluation?.priceDrop,
visitLocation: claim.evaluation?.visitLocation,
currentStep: claim.workflow?.currentStep,
nextStep: claim.workflow?.nextStep,
};
}
private normalizeBlameCase(blame: any): any {
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
return {
...blame,
requestNumber: blame.requestNo,
rating: blame?.expert?.rating,
actorLocked: { actorId: blame?.expert?.assignedExpertId },
firstPartyDetails: {
firstPartyClient: { clientId: firstParty?.person?.clientId },
},
secondPartyDetails: {
secondPartyClient: { clientId: secondParty?.person?.clientId },
},
};
}
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
private blameFieldExpertIdCandidates(b: any): string[] {
const raw: unknown[] = [
b?.expert?.assignedExpertId,
b?.initiatedByFieldExpertId,
b?.expert?.decision?.decidedByExpertId,
];
const out = new Set<string>();
for (const x of raw) {
if (x != null && x !== "") out.add(String(x));
}
return [...out];
}
private claimDamageExpertActorId(c: any): string | null {
const reply =
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
const aid = reply?.actorDetail?.actorId;
if (aid == null || aid === "") return null;
return String(aid);
}
private mapBlameFileSummaryForInsurerExpert(b: any) {
return {
kind: "blame" as const,
requestId: String(b._id),
publicId: b.publicId,
requestNo: b.requestNo,
type: b.type,
status: b.status,
blameStatus: b.blameStatus,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
};
}
private mapClaimFileSummaryForInsurerExpert(c: any) {
return {
kind: "claim" as const,
requestId: String(c._id),
publicId: c.publicId,
requestNo: c.requestNo ?? c.requestNumber,
status: c.status,
claimStatus: c.claimStatus,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
}
private buildPartiesPreview(parties: any[] | undefined) {
if (!Array.isArray(parties) || !parties.length) return undefined;
return parties.map((p: any) => ({
role: p?.role,
fullName: p?.person?.fullName,
vehicle: {
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
},
}));
}
private sanitizeVehicleInquiry(vehicle: any) {
if (!vehicle || typeof vehicle !== "object") return vehicle;
const inquiry = vehicle.inquiry;
if (!inquiry || typeof inquiry !== "object") return vehicle;
const { raw, mapped, ...safeInquiry } = inquiry;
return {
...vehicle,
inquiry: safeInquiry,
};
}
private sanitizePartyForInsurerView(party: any) {
if (!party || typeof party !== "object") return party;
return {
...party,
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
};
}
private getCombinedFileScore(file: any): number | null {
const insurerRating = file?.rating;
const userRating = file?.userRating;
const insurerValues = insurerRating
? (
[
insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
] as unknown[]
).filter((v): v is number => typeof v === "number" && !isNaN(v))
: [];
const userValues = userRating
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
(v) => typeof v === "number" && !isNaN(v),
)
: [];
const insurerAvg = insurerValues.length
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
: null;
const userAvg = userValues.length
? userValues.reduce((a, b) => a + b, 0) / userValues.length
: null;
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number");
if (!scores.length) return null;
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
}
private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
const idStr = String(clientObjectId);
return all
.filter((f) =>
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
)
.map((f) => this.normalizeBlameCase(f));
}
private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
const all = (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<string, number[]> = {};
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
const processRatings = (expertId: string | undefined, file: any) => {
if (!expertId) return;
const rating = file?.rating;
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore !== null) {
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId].push(combinedScore);
}
if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) {
if (category === "botRating") continue;
if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) {
expertRatingsByCategoryMap[expertId][category] = [];
}
expertRatingsByCategoryMap[expertId][category].push(value);
}
}
};
for (const file of blameFiles) {
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
}
for (const file of claimFiles) {
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
}
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length
? parseFloat(
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
)
: null;
const averageRatingsByCategory: Record<string, number> = {};
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
if (ratingsByCat) {
for (const [category, values] of Object.entries(ratingsByCat)) {
averageRatingsByCategory[category] = parseFloat(
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
);
}
}
return {
_id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`,
role: expert.role,
expertKind,
type: expert.userType,
requestStats: expertActivityStatsMap[expertIdStr] ?? {
totalHandled: 0,
totalChecked: 0,
},
createdAt: expert.createdAt,
overallAverageRating,
averageRatingsByCategory,
};
};
const allExperts = [
...experts.map((e) => mapExpertRow(e, "blame")),
...damageExperts.map((e) => mapExpertRow(e, "claim")),
];
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
const start = (page - 1) * perPage;
return {
total: allExperts.length,
page,
countPerPage: perPage,
experts: allExperts.slice(start, start + perPage),
};
}
/**
* Top experts per roster: blame rows come from the `expert` collection (files they
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
* Scores are aggregated from **file documents** that insurer can see: for each such
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
* excluding `botRating`), optionally blend with the files user rating, average those
* combined scores per file, then average across that experts files (`overallAverageRating`).
*/
async getTopExpertsForClient(actor): Promise<{
blameExperts: any[];
claimExperts: any[];
}> {
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
const rows = result?.experts || [];
const byRatingDesc = (a: any, b: any) =>
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
const blameExperts = rows
.filter((e) => e.expertKind === "blame")
.sort(byRatingDesc)
.slice(0, 10);
const claimExperts = rows
.filter((e) => e.expertKind === "claim")
.sort(byRatingDesc)
.slice(0, 10);
return { blameExperts, claimExperts };
}
/**
* Returns top 10 claim files for the current insurer client based on
* combined insurer + user ratings.
*/
async getTopFilesForClient(insurerId: string): Promise<any[]> {
const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
const scored = claimFiles
.map((file) => {
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore === null) return null;
return { ...file, combinedScore };
})
.filter((f) => f !== null);
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
}
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
const expertObjectId = this.parseObjectId(expertId, "expert id");
const clientOid = this.getClientId(insurerClientKey);
const ckFilter = this.clientKeyScopeFilter(clientOid);
const [experts, damageExperts] = 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,
};
}
}