diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 4442d07..4100b2a 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -7,6 +7,11 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { + assertBlameCaseForExpertTenant, + blameCaseAccessibleToExpert, + requireActorClientKey, +} from "src/helpers/tenant-scope"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -185,21 +190,21 @@ export class ExpertBlameService { */ async findAllV2(actor: any): Promise { try { + requireActorClientKey(actor); const expertId = actor.sub; - - // Fetch all DISAGREEMENT cases - const allCases = await this.blameRequestDbService.find( - { - blameStatus: BlameStatus.DISAGREEMENT, - }, - { lean: true }, - ); + + const allCases = await this.blameRequestDbService.find({}, { lean: true }); // Filter to show only: - // 1. Fresh requests (WAITING_FOR_EXPERT and no decision) - // 2. Requests decided by current expert - // 3. Expert-initiated: only the initiating field expert sees them + // 1. Same insurance tenant (party clientId or expert-initiated by this actor) + // 2. Fresh requests (WAITING_FOR_EXPERT and no decision) + // 3. Requests decided by current expert + // 4. Expert-initiated: only the initiating field expert sees them const visibleCases = (allCases as Record[]).filter((doc) => { + if (!blameCaseAccessibleToExpert(doc, actor)) { + return false; + } + const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; @@ -520,12 +525,15 @@ export class ExpertBlameService { * Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence. * Access control: Only allows viewing fresh requests or requests decided by current expert. */ - async findOneV2(requestId: string, actorId: string): Promise> { + async findOneV2(requestId: string, actor: any): Promise> { try { + requireActorClientKey(actor); + const actorId = actor.sub; const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); if (!doc) { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(doc, actor); const type = doc.type as string; if (type === BlameRequestType.CAR_BODY) { throw new ForbiddenException( @@ -668,6 +676,8 @@ export class ExpertBlameService { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actorDetail); + // Validate request is available for expert review if ( request.status !== CaseStatus.WAITING_FOR_EXPERT || @@ -696,7 +706,9 @@ export class ExpertBlameService { const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; if (Date.now() < lockExpiryTime) { // Lock is still valid - const lockedByActorId = String(request.workflow.lockedBy?.actorId); + const lockedByActorId = String( + request.workflow.lockedBy?.actorId ?? "", + ); if (lockedByActorId === actorDetail.sub) { throw new BadRequestException( "You have already locked this request", @@ -755,15 +767,19 @@ export class ExpertBlameService { async resendRequestV2( requestId: string, resendDto: ResendRequestDto, - actorId: string, + actor: any, ): Promise<{ requestId: string; status: string }> { try { + requireActorClientKey(actor); + const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actor); + // Validate request is locked by current expert if (!request.workflow?.locked) { throw new ForbiddenException( @@ -872,15 +888,19 @@ export class ExpertBlameService { async replyRequestV2( requestId: string, reply: SubmitReplyDto, - actorId: string, + actor: any, ): Promise<{ requestId: string; status: string }> { try { + requireActorClientKey(actor); + const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actor); + // Validate no decision exists yet if (request.expert?.decision) { throw new ForbiddenException( diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index ce42b7f..e372156 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -42,7 +42,7 @@ export class ExpertBlameV2Controller { @ApiParam({ name: "id", description: "Blame case request id" }) async findOne(@Param("id") id: string, @CurrentUser() actor: any) { try { - return await this.expertBlameService.findOneV2(id, actor.sub); + return await this.expertBlameService.findOneV2(id, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( @@ -73,7 +73,7 @@ export class ExpertBlameV2Controller { @CurrentUser() actor: any, ) { try { - return await this.expertBlameService.resendRequestV2(id, body, actor.sub); + return await this.expertBlameService.resendRequestV2(id, body, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( @@ -93,7 +93,7 @@ export class ExpertBlameV2Controller { @CurrentUser() actor: any, ) { try { - return await this.expertBlameService.replyRequestV2(id, body, actor.sub); + return await this.expertBlameService.replyRequestV2(id, body, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 210954b..ee7f0c7 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -32,6 +32,11 @@ import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; +import { + assertClaimCaseForTenant, + claimCaseTouchesClient, + requireActorClientKey, +} from "src/helpers/tenant-scope"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto"; import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto"; @@ -1575,12 +1580,15 @@ export class ExpertClaimService { * - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT */ async lockClaimRequestV2(claimRequestId: string, actor: any) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { throw new BadRequestException( `Claim is not available for locking. Current status: ${claim.status}`, @@ -1655,12 +1663,15 @@ export class ExpertClaimService { reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto, actor: any, ) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in a reviewable state. Current status: ${claim.status}`, @@ -1762,12 +1773,15 @@ export class ExpertClaimService { * - Unlocks the workflow so user can act */ async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, @@ -1815,7 +1829,10 @@ export class ExpertClaimService { * 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING) * 3. Any status locked by THIS expert (their own in-progress work) */ - async getClaimListV2(actorId: string): Promise { + async getClaimListV2(actor: any): Promise { + requireActorClientKey(actor); + const actorId = actor.sub; + const clientKey = actor.clientKey as string; const claims = await this.claimCaseDbService.find({ $or: [ // Available claims: waiting for expert, not locked @@ -1831,7 +1848,9 @@ export class ExpertClaimService { ], }); - const list = (claims as any[]).map((c) => ({ + const list = (claims as any[]) + .filter((c) => claimCaseTouchesClient(c, clientKey)) + .map((c) => ({ claimRequestId: c._id.toString(), publicId: c.publicId, status: c.status, @@ -1866,14 +1885,18 @@ export class ExpertClaimService { */ async getClaimDetailV2( claimRequestId: string, - actorId: string, + actor: any, ): Promise { + requireActorClientKey(actor); + const actorId = actor.sub; const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { throw new ForbiddenException( `This claim is not available for expert review. Current status: ${claim.status}`, @@ -1980,10 +2003,12 @@ export class ExpertClaimService { body: UpdateClaimDamagedPartsV2Dto, actor: any, ) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } + assertClaimCaseForTenant(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index cbc6c57..2883c0d 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -39,7 +39,7 @@ export class ExpertClaimV2Controller { "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.", }) async getClaimListV2(@CurrentUser() actor) { - return await this.expertClaimService.getClaimListV2(actor.sub); + return await this.expertClaimService.getClaimListV2(actor); } @Get("request/:claimRequestId") @@ -53,10 +53,7 @@ export class ExpertClaimV2Controller { @Param("claimRequestId") claimRequestId: string, @CurrentUser() actor, ) { - return await this.expertClaimService.getClaimDetailV2( - claimRequestId, - actor.sub, - ); + return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor); } @Put("lock/:claimRequestId") diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 3dd0265..5808ac5 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -78,6 +78,7 @@ export class ExpertInsurerController { @ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Get("/:expertId") async requestDetail( + @CurrentUser() insurer, @Param("expertId") id: string, @Query("role") role: "claim" | "blame", ) { @@ -89,7 +90,11 @@ export class ExpertInsurerController { throw new BadRequestException("Invalid role"); } - return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role); + return await this.expertInsurerService.getAllFilesByExpertAndRole( + id, + role, + insurer.clientKey, + ); } @ApiBody({ @@ -153,6 +158,7 @@ export class ExpertInsurerController { @ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Put("/:requestId/rating") async rateExperts( + @CurrentUser() insurer, @Param("requestId") requestId: string, @Body() rating: FileRating, @Query("role") role: "claim" | "blame", @@ -165,13 +171,13 @@ export class ExpertInsurerController { requestId, rating, role, + insurer.clientKey, ); } @Get("branches/:insuranceId") @ApiParam({ name: "insuranceId" }) async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { - console.log("insuranceId", insuranceId); return await this.expertInsurerService.retrieveInsuranceBranches( insuranceId, ); diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 441967a..cda0370 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -1,163 +1,159 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, - Logger, NotFoundException, } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model, Types } from "mongoose"; -import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service"; -import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; -import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; -import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema"; +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 { ClientDbService } from "src/client/entities/db-service/client.db.service"; -import { buildFileLink } from "src/helpers/urlCreator"; -import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; -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 { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service"; import { - FileRating, - RequestManagementModel, -} from "src/request-management/entities/schema/request-management.schema"; + 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"; @Injectable() export class ExpertInsurerService { - private readonly logger = new Logger(ExpertInsurerService.name); - constructor( - private readonly client: ClientDbService, private readonly expertDbService: ExpertDbService, private readonly damageExpertDbService: DamageExpertDbService, - @InjectModel(ClaimRequestManagementModel.name) - private readonly claimRequestManagementModel: Model, - @InjectModel(RequestManagementModel.name) - private readonly requestManagementModel: Model, - private readonly blameVoiceDbService: BlameVoiceDbService, - private readonly blameVideoDbService: BlameVideoDbService, - private readonly blameDocumentDbService: BlameDocumentDbService, - private readonly userSignDbService: UserSignDbService, - private readonly claimVideoCaptureDbService: VideoCaptureDbService, + private readonly blameRequestDbService: BlameRequestDbService, + private readonly claimCaseDbService: ClaimCaseDbService, private readonly branchDbService: BranchDbService, - private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, - private readonly damageImageDbService: DamageImageDbService, ) {} - async retrieveAllExpertsOfClient( - actor, - currentPage: number, - countPerPage: number, - ) { - const { clientKey } = actor; - if (!clientKey) return; + 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); + } - const clientObjectId = new Types.ObjectId(clientKey); + private parseObjectId(value: string, label: string): Types.ObjectId { + if (!Types.ObjectId.isValid(value)) { + throw new BadRequestException(`Invalid ${label}`); + } + return new Types.ObjectId(value); + } - const [experts, damageExperts] = await Promise.all([ - this.expertDbService.findAll({ clientKey: clientKey }), + 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((expert) => expert._id.toString()); - - if (expertIds.length === 0) - return { total: 0, page: currentPage, countPerPage, experts: [] }; - - const expertObjectIds = expertIds.map((id) => new Types.ObjectId(id)); - - const blameFileQuery = { - $and: [ - { - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientObjectId }, - { "secondPartyDetails.secondPartyClient.clientId": clientObjectId }, - ], - }, - { "actorLocked.actorId": { $in: expertObjectIds } }, - ], - }; - - const claimFileQuery = { - $and: [ - { userClientKey: clientObjectId }, - { "damageExpertReply.actorDetail.actorId": { $in: expertIds } }, - ], - }; - - const [blameFiles, claimFiles] = await Promise.all([ - this.requestManagementModel.find(blameFileQuery).lean(), - this.claimRequestManagementModel.find(claimFileQuery).lean(), - ]); - const expertTotalRatingsMap: Record = {}; - const expertRatingsByCategoryMap: Record< - string, - Record - > = {}; + const expertRatingsByCategoryMap: Record> = {}; - /** - * Converts insurer FileRating and optional userRating into a single - * combined score for a file. - */ - const getCombinedFileScore = (file: any): number | null => { - const insurerRating = file?.rating; - const userRating = file?.userRating; - - const insurerValues = insurerRating - ? Object.values(insurerRating).filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ) - : []; - const insurerAvg = - insurerValues.length > 0 - ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length - : null; - - const userValues = userRating - ? [ - userRating.progressSpeed, - userRating.registrationEase, - userRating.overallEvaluation, - ].filter((val) => typeof val === "number" && !isNaN(val)) - : []; - const userAvg = - userValues.length > 0 - ? userValues.reduce((a, b) => a + b, 0) / userValues.length - : null; - - const scores = [insurerAvg, userAvg].filter( - (v): v is number => typeof v === "number" && !isNaN(v), - ); - if (scores.length === 0) return null; - return parseFloat( - (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2), - ); - }; - - const processRatings = (expertId: string, file: any) => { + const processRatings = (expertId: string | undefined, file: any) => { if (!expertId) return; - const rating = file?.rating; - const combinedScore = getCombinedFileScore(file); - - // Aggregate overall combined score for this expert + const combinedScore = this.getCombinedFileScore(file); if (combinedScore !== null) { - if (!expertTotalRatingsMap[expertId]) - expertTotalRatingsMap[expertId] = []; + if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = []; expertTotalRatingsMap[expertId].push(combinedScore); } - - // Keep backward-compatible per-category insurer ratings if (!rating || typeof rating !== "object") return; - - if (!expertRatingsByCategoryMap[expertId]) { - expertRatingsByCategoryMap[expertId] = {}; - } + if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {}; for (const [category, value] of Object.entries(rating)) { if (typeof value === "number" && !isNaN(value)) { if (!expertRatingsByCategoryMap[expertId][category]) { @@ -169,37 +165,29 @@ export class ExpertInsurerService { }; for (const file of blameFiles) { - const expertId = file?.actorLocked?.actorId?.toString(); - processRatings(expertId, file); + processRatings(file?.actorLocked?.actorId?.toString?.(), file); } - for (const file of claimFiles) { - const expertId = file?.damageExpertReply?.actorDetail?.actorId; - processRatings(expertId, file); + 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), + (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)) { - const sum = values.reduce((a, b) => a + b, 0); - const average = parseFloat((sum / values.length).toFixed(2)); - averageRatingsByCategory[category] = average; + averageRatingsByCategory[category] = parseFloat( + (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2), + ); } } - return { _id: expert._id, fullName: `${expert.firstName} ${expert.lastName}`, @@ -212,14 +200,14 @@ export class ExpertInsurerService { }; }); - const start = (currentPage - 1) * countPerPage; - const paginated = allExperts.slice(start, start + countPerPage); - + 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: currentPage, - countPerPage, - experts: paginated, + page, + countPerPage: perPage, + experts: allExperts.slice(start, start + perPage), }; } @@ -245,510 +233,125 @@ export class ExpertInsurerService { * combined insurer + user ratings. */ async getTopFilesForClient(insurerId: string): Promise { - const id = new Types.ObjectId(insurerId); - - const claimFiles = await this.claimRequestManagementModel - .find( - { - userClientKey: id, - }, - { - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - carPlate: 1, - claimStatus: 1, - rating: 1, - userRating: 1, - damageExpertReply: 1, - damageExpertReplyFinal: 1, - createdAt: 1, - }, - ) - .lean(); - + const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); const scored = claimFiles .map((file) => { - const insurerRating = file?.rating; - const userRating = file?.userRating; - - const insurerValues = insurerRating - ? Object.values(insurerRating).filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ) - : []; - const insurerAvg = - insurerValues.length > 0 - ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length - : null; - - const userValues = userRating - ? [ - userRating.progressSpeed, - userRating.registrationEase, - userRating.overallEvaluation, - ].filter((val) => typeof val === "number" && !isNaN(val)) - : []; - const userAvg = - userValues.length > 0 - ? userValues.reduce((a, b) => a + b, 0) / userValues.length - : null; - - const scores = [insurerAvg, userAvg].filter( - (v): v is number => typeof v === "number" && !isNaN(v), - ); - if (scores.length === 0) return null; - - const combinedScore = - scores.reduce((a, b) => a + b, 0) / scores.length; - - return { - ...file, - combinedScore: parseFloat(combinedScore.toFixed(2)), - }; + const combinedScore = this.getCombinedFileScore(file); + if (combinedScore === null) return null; + return { ...file, combinedScore }; }) .filter((f) => f !== null); - - const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore); - return sorted.slice(0, 10); + return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); } - async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") { - const expertObjectId = new Types.ObjectId(expertId); - - const model = - role === "claim" - ? this.claimRequestManagementModel - : this.requestManagementModel; - const expertCollection = role === "claim" ? "damage-expert" : "expert"; - - const results = await model.aggregate([ - { $match: { "actorLocked.actorId": expertObjectId } }, - { - $lookup: { - from: expertCollection, - localField: "actorLocked.actorId", - foreignField: "_id", - as: "expertInfo", - }, - }, - { $unwind: { path: "$expertInfo", preserveNullAndEmptyArrays: true } }, - - { - $addFields: { - averageRating: { - $cond: [ - { $ne: ["$rating", null] }, - { - $avg: [ - "$rating.collisionMethodAccuracy", - "$rating.evaluationTimeliness", - "$rating.accidentCauseAccuracy", - "$rating.guiltyVehicleIdentification", - ], - }, - null, - ], - }, - }, - }, - - { - $project: { - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - claimStatus: 1, - steps: 1, - currentStep: 1, - rating: 1, - averageRating: 1, - imageRequired: 1, - expertInfo: { - _id: 1, - fullName: { - $concat: ["$expertInfo.firstName", " ", "$expertInfo.lastName"], - }, - requestStats: 1, - userType: 1, - createdAt: 1, - }, - }, - }, + 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 }), ]); - - // Process imageRequired for claim files - if (role === "claim") { - return await Promise.all( - results.map((file) => this.processImageRequired(file)), + 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.", ); } + } - return results; + 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 = new Types.ObjectId(requestId); - - // Validate each rating factor - const fields = Object.entries(rating); - for (const [key, value] of fields) { + 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`, - ); + throw new BadRequestException(`${key} must be a number between 0 and 5`); } } - if (role === "claim") { - const updated = await this.claimRequestManagementModel.findByIdAndUpdate( - _id, - { $set: { rating } }, - { new: true }, - ); - - if (!updated) { - throw new NotFoundException("Claim file not found"); + 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.rating, + updatedRating: (updated as any)?.evaluation?.rating || rating, requestId: updated._id, }; } - if (role === "blame") { - const updated = await this.requestManagementModel.findByIdAndUpdate( - _id, - { $set: { rating } }, - { new: true }, - ); - - if (!updated) { - throw new NotFoundException("Blame file not found"); + 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.rating, + updatedRating: (updated as any)?.expert?.rating || rating, requestId: updated._id, }; } - throw new BadRequestException("Invalid role"); } async retrieveAllFilesOfClient(insurerId: string) { - const id = new Types.ObjectId(insurerId); - - const blameFilesRaw = await this.requestManagementModel - .find({ - "firstPartyDetails.firstPartyClient.clientId": id, - blameStatus: { - $nin: ["PendingForFirstParty", "PendingForSecondParty"], - }, - }) - .lean(); - - const claimFilesRaw = await this.claimRequestManagementModel - .find({ - userClientKey: id, - }, { - _id: 1, - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - carPlate: 1, - claimStatus: 1, - currentStep: 1, - nextStep: 1, - carPartDamage: 1, - otherParts: 1, - sheba: 1, - nationalCodeOfInsurer: 1, - carGreenCard: 1, - aiImages: 1, - imageRequired: 1, - videoCaptureId: 1, - damageExpertReply: 1, - damageExpertReplyFinal: 1, - damageExpertResend: 1, - objection: 1, - userResendDocuments: 1, - priceDrop: 1, - requiredDocuments: 1, - createdAt: 1, - updatedAt: 1, - rating: 1, - visitLocation: 1, - }) - .lean(); - - const populatedBlameFiles = await Promise.all( - blameFilesRaw.map((file) => this.populateBlameFileLinks(file)), - ); - const populatedClaimFiles = await Promise.all( - claimFilesRaw.map(async (file) => { - const populated = await this.populateClaimFileLinks(file); - return await this.processImageRequired(populated); - }), - ); - - return { blameFiles: populatedBlameFiles, claimFiles: populatedClaimFiles }; - } - - private async populateBlameFileLinks(blameFile: any): Promise { - if (!blameFile) return blameFile; - - // --- FIX: Consistently use findById --- - if (blameFile.firstPartyDetails?.firstPartyFile?.firstPartyVideoId) { - const videoDoc = await this.blameVideoDbService.findById( - blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId.toString(), - ); - if (videoDoc) - blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId = - buildFileLink(videoDoc.path); - } - if (Array.isArray(blameFile.firstPartyDetails?.firstPartyFile?.voices)) { - blameFile.firstPartyDetails.firstPartyFile.voices = - await this.populateIdArray( - this.blameVoiceDbService, - blameFile.firstPartyDetails.firstPartyFile.voices, - ); - } - if (Array.isArray(blameFile.secondPartyDetails?.secondPartyFiles?.voices)) { - blameFile.secondPartyDetails.secondPartyFiles.voices = - await this.populateIdArray( - this.blameVoiceDbService, - blameFile.secondPartyDetails.secondPartyFiles.voices, - ); - } - - if (blameFile.expertResendReply) { - await this.populatePartyReplyLinks( - blameFile.expertResendReply.firstParty, - ); - await this.populatePartyReplyLinks( - blameFile.expertResendReply.secondParty, - ); - } - - const finalReply = - blameFile.expertSubmitReplyFinal || blameFile.expertSubmitReply; - if (finalReply) { - await this.populateSignatureLink(finalReply.firstPartyComment); - await this.populateSignatureLink(finalReply.secondPartyComment); - } - - return blameFile; - } - - private async populateClaimFileLinks(claimFile: any): Promise { - if (!claimFile) return claimFile; - - if (claimFile.blameFile) { - claimFile.blameFile = await this.populateBlameFileLinks( - claimFile.blameFile, - ); - } - - // --- FIX: Consistently use findById --- - if (claimFile.videoCaptureId) { - const videoDoc = await this.claimVideoCaptureDbService.findById( - claimFile.videoCaptureId._id.toString(), - ); - if (videoDoc) claimFile.videoCaptureId = buildFileLink(videoDoc.path); - } - - if (claimFile.requiredDocuments) { - const documents = await this.claimRequiredDocumentDbService.findByClaimId( - claimFile._id.toString(), - ); - - // Populate with file URLs - const populatedDocuments = documents.map((doc) => ({ - _id: doc._id, - documentType: doc.documentType, - fileName: doc.fileName, - fileUrl: buildFileLink(doc.path), - uploadedAt: doc.uploadedAt, - })); - - claimFile.requiredDocuments = populatedDocuments; - } - - return claimFile; - } - - /** - * Processes imageRequired field: - * 1. Removes part_segments from aiReport.distinct_damaged_parts_report.parts[] - * 2. Populates imageId fields with file links - */ - private async processImageRequired(claimFile: any): Promise { - if (!claimFile || !claimFile.imageRequired) { - return claimFile; - } - - const imageRequired = claimFile.imageRequired; - - // Process aroundTheCar array - if (Array.isArray(imageRequired.aroundTheCar)) { - imageRequired.aroundTheCar = await Promise.all( - imageRequired.aroundTheCar.map(async (item: any) => { - // Remove part_segments from aiReport.distinct_damaged_parts_report.parts[] - if ( - item?.aiReport?.distinct_damaged_parts_report?.parts && - Array.isArray(item.aiReport.distinct_damaged_parts_report.parts) - ) { - item.aiReport.distinct_damaged_parts_report.parts = - item.aiReport.distinct_damaged_parts_report.parts.map( - (part: any) => { - const { part_segments, ...partWithoutSegments } = part; - return partWithoutSegments; - }, - ); - } - - // Populate imageId with file link - if (item?.imageId) { - try { - const imageDoc = await this.damageImageDbService.findOne( - item.imageId.toString(), - ); - if (imageDoc && imageDoc.path) { - item.imageId = buildFileLink(imageDoc.path); - } - } catch (error) { - this.logger.warn( - `Failed to populate imageId for aroundTheCar item: ${error.message}`, - ); - } - } - - return item; - }), - ); - } - - // Process selectPartOfCar array - if (Array.isArray(imageRequired.selectPartOfCar)) { - imageRequired.selectPartOfCar = await Promise.all( - imageRequired.selectPartOfCar.map(async (item: any) => { - // Remove part_segments from aiReport.distinct_damaged_parts_report.parts[] - if ( - item?.aiReport?.distinct_damaged_parts_report?.parts && - Array.isArray(item.aiReport.distinct_damaged_parts_report.parts) - ) { - item.aiReport.distinct_damaged_parts_report.parts = - item.aiReport.distinct_damaged_parts_report.parts.map( - (part: any) => { - const { part_segments, ...partWithoutSegments } = part; - return partWithoutSegments; - }, - ); - } - - // Populate imageId with file link - if (item?.imageId) { - try { - const imageDoc = await this.damageImageDbService.findOne( - item.imageId.toString(), - ); - if (imageDoc && imageDoc.path) { - item.imageId = buildFileLink(imageDoc.path); - } - } catch (error) { - this.logger.warn( - `Failed to populate imageId for selectPartOfCar item: ${error.message}`, - ); - } - } - - return item; - }), - ); - } - - return claimFile; - } - - private async populatePartyReplyLinks(partyReply: any) { - if (!partyReply) return; - // --- FIX: Consistently use findById --- - if (partyReply.voice) { - const voiceDoc = await this.blameVoiceDbService.findById( - partyReply.voice.toString(), - ); - if (voiceDoc) partyReply.voice = buildFileLink(voiceDoc.path); - } - if (partyReply.documents) { - for (const docType in partyReply.documents) { - const docId = partyReply.documents[docType]; - if (docId) { - const doc = await this.blameDocumentDbService.findById( - docId.toString(), - ); - if (doc) partyReply.documents[docType] = buildFileLink(doc.path); - } - } - } - } - - private async populateSignatureLink(comment: any) { - if (comment?.signDetail?.fileId) { - // --- FIX: Consistently use findById --- - const signDoc = await this.userSignDbService.findById( - comment.signDetail.fileId.toString(), - ); - if (signDoc) - (comment.signDetail as any).fileUrl = buildFileLink(signDoc.path); - } - } - - private async populateIdArray(dbService: any, ids: any[]): Promise { - return Promise.all( - ids.map(async (id) => { - if (!id) return id; - - if (typeof id === "object" && id.path) { - return buildFileLink(id.path); - } - - const idString = id.toString(); - if (idString.includes("[object Object]")) { - this.logger.warn( - "Invalid object found in ID array, skipping population.", - id, - ); - return id; - } - - try { - // --- FIX: Consistently use findById --- - const doc = await dbService.findById(idString); - return doc ? buildFileLink(doc.path) : id; - } catch (error) { - this.logger.error(`Failed to populate ID: ${idString}`, error); - return id; - } - }), - ); + 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 = new Types.ObjectId(clientKey); + const clientId = this.getClientId(clientKey); const existingBranch = await this.branchDbService.findOne({ clientKey: clientId, @@ -778,19 +381,8 @@ export class ExpertInsurerService { * - Number of files created in the current month */ async getExpertStatisticsReport(actor: any) { - const { clientKey } = actor; - if (!clientKey) { - throw new BadRequestException("Client key is required"); - } - - const clientObjectId = new Types.ObjectId(clientKey); - - // Get all claim files for this client - const claimFiles = await this.claimRequestManagementModel - .find({ - userClientKey: clientObjectId, - }) - .lean(); + const clientObjectId = this.getClientId(actor); + const claimFiles = await this.getClientClaimFiles(clientObjectId); // Calculate current month date range const now = new Date(); @@ -907,6 +499,6 @@ export class ExpertInsurerService { } async retrieveInsuranceBranches(insuranceId: string) { - return await this.branchDbService.findAll(insuranceId); + return this.branchDbService.findAll(insuranceId); } } diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts new file mode 100644 index 0000000..b87bc91 --- /dev/null +++ b/src/helpers/tenant-scope.ts @@ -0,0 +1,66 @@ +import { BadRequestException, ForbiddenException } from "@nestjs/common"; + +/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */ +export function requireActorClientKey(actor: { clientKey?: string }): string { + const ck = actor?.clientKey; + if (!ck || typeof ck !== "string") { + throw new BadRequestException( + "Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.", + ); + } + return ck; +} + +export function blameCaseTouchesClient(doc: any, clientKey: string): boolean { + const id = String(clientKey); + return (doc?.parties ?? []).some( + (p: any) => String(p?.person?.clientId ?? "") === id, + ); +} + +export function claimCaseTouchesClient(doc: any, clientKey: string): boolean { + return String(doc?.owner?.userClientKey ?? "") === String(clientKey); +} + +/** + * Field expert–initiated cases may not yet have party clientId filled; the + * initiating expert is always scoped to their insurer via JWT. + */ +export function blameCaseAccessibleToExpert( + doc: any, + actor: { sub: string; clientKey?: string }, +): boolean { + if ( + doc.expertInitiated && + doc.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) === actor.sub + ) { + return true; + } + const ck = actor?.clientKey; + if (!ck) return false; + return blameCaseTouchesClient(doc, ck); +} + +export function assertBlameCaseForExpertTenant( + doc: any, + actor: { sub: string; clientKey?: string }, +): void { + if (!blameCaseAccessibleToExpert(doc, actor)) { + throw new ForbiddenException( + "This blame case does not belong to your organization.", + ); + } +} + +export function assertClaimCaseForTenant( + doc: any, + actor: { clientKey?: string }, +): void { + const ck = requireActorClientKey(actor); + if (!claimCaseTouchesClient(doc, ck)) { + throw new ForbiddenException( + "This claim does not belong to your organization.", + ); + } +} diff --git a/src/reports/dto/reports.dto.ts b/src/reports/dto/reports.dto.ts index a9e382e..8864f19 100644 --- a/src/reports/dto/reports.dto.ts +++ b/src/reports/dto/reports.dto.ts @@ -1,50 +1,28 @@ -export class DamageExpertAllRequestsCountReportDtoRs { - all: number; - UnChecked: number; - CheckedRequest: number; - CheckAgain: number; - WaitingForUserToResend: number; - CloseRequest: number; - WaitingForUserCompleted: number; - InPersonVisit: number; +/** + * Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`), + * plus `all` = total matching documents for the tenant. + */ +export class BlameCaseStatusCountReportDtoRs { + [key: string]: number; - constructor(report: any) { - this.all = report.all; - this.UnChecked = report.UnChecked; - this.CheckedRequest = report.CheckedRequest; - this.CheckAgain = report.CheckAgain; - this.WaitingForUserToResend = report.WaitingForUserToResend; - this.CloseRequest = report.CloseRequest; - this.WaitingForUserCompleted = report.WaitingForUserCompleted; - this.InPersonVisit = report.InPersonVisit; + constructor(report: Record) { + Object.assign(this, report); } } -export class ExpertAllRequestsCountReportDtoRs { - all: number; - UnChecked: number; - CheckedRequest: number; - CheckAgain: number; - WaitingForUserToResend: number; - CloseRequest: number; - WaitingForUserCompleted: number; +export class ClaimCaseStatusCountReportDtoRs { + [key: string]: number; - constructor(report: any) { - this.all = report.all; - this.UnChecked = report.UnChecked; - this.CheckedRequest = report.CheckedRequest; - this.CheckAgain = report.CheckAgain; - this.WaitingForUserToResend = report.WaitingForUserToResend; - this.CloseRequest = report.CloseRequest; - this.WaitingForUserCompleted = report.WaitingForUserCompleted; + constructor(report: Record) { + Object.assign(this, report); } } export class CompanyAllRequestsCountReportDtoRs { - blame: object; - claim: object; + blame: Record; + claim: Record; - constructor(blame: object, claim: object) { + constructor(blame: Record, claim: Record) { this.blame = blame; this.claim = claim; } diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts index 0fb42c3..c735eb3 100644 --- a/src/reports/reports.module.ts +++ b/src/reports/reports.module.ts @@ -1,7 +1,6 @@ import { Module } from "@nestjs/common"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module"; -import { ClientModule } from "src/client/client.module"; import { ReportsController } from "./reports.controller"; import { ReportsService } from "./reports.service"; @@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service"; imports: [ RequestManagementModule, ClaimRequestManagementModule, - ClientModule, ], controllers: [ReportsController], providers: [ReportsService], diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index e34a808..c82e782 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -1,413 +1,243 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { Types } from "mongoose"; -import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; -import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; -import { ClientDbService } from "src/client/entities/db-service/client.db.service"; -import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; -import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum"; -import { UserType } from "src/Types&Enums/userType.enum"; +import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; +import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; +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 { requireActorClientKey } from "src/helpers/tenant-scope"; import { + BlameCaseStatusCountReportDtoRs, + ClaimCaseStatusCountReportDtoRs, CompanyAllRequestsCountReportDtoRs, - DamageExpertAllRequestsCountReportDtoRs, - ExpertAllRequestsCountReportDtoRs, } from "./dto/reports.dto"; @Injectable() export class ReportsService { - private readonly logger = new Logger(ReportsService.name); - constructor( - private readonly requestManagementDbService: RequestManagementDbService, - private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, - private readonly clientDbService: ClientDbService, + private readonly blameRequestDbService: BlameRequestDbService, + private readonly claimCaseDbService: ClaimCaseDbService, ) {} - async getAllCheckedRequestsCountFn(role: string, client: string) { - const statuses = ["UnChecked", "CheckedRequest"]; - const data: Record = { all: 0 }; - const clientId = new Types.ObjectId(client); - - for (const status of statuses) { - let count = 0; - if (role === "damage_expert") { - count = await this.claimRequestManagementDbService.countByFilter({ - claimStatus: status, - userClientKey: clientId, - }); - } else { - count = await this.requestManagementDbService.countByFilter({ - blameStatus: status, - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientId }, - { "secondPartyDetails.secondPartyClient.clientId": clientId }, - ], - }); - } - data[status] = count; - data.all += count; - } - return data; + private clientObjectId(client: string | undefined): Types.ObjectId { + const ck = requireActorClientKey({ clientKey: client }); + return new Types.ObjectId(ck); } - async getDateFilteredRequestByStatus( - role: string, - client: string, - start: Date, - end: Date, - ) { - const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"]; - const data: Record = {}; - const clientId = new Types.ObjectId(client); - - for (const status of statuses) { - let blameCount = 0; - let claimCount = 0; - - if (role === "expert" || role === "company") { - blameCount = await this.requestManagementDbService.countByFilter({ - blameStatus: status, - createdAt: { $gte: start, $lte: end }, - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientId }, - { "secondPartyDetails.secondPartyClient.clientId": clientId }, - ], - }); - } - - if (role === "damage_expert" || role === "company") { - claimCount = await this.claimRequestManagementDbService.countByFilter({ - claimStatus: status, - userClientKey: clientId, - createdAt: { $gte: start, $lte: end }, - }); - } - data[status] = blameCount + claimCount; - } - return data; - } - - private isVisibleToClientType(client: any, actor: any): boolean { - if (actor.userType === UserType.GENUINE) { - return true; - } - if ( - actor.userType === UserType.LEGAL && - String(client._id) === actor.clientKey - ) { - return true; - } - return false; - } - - private wasHandledByActor(request: any, actorSub: string): boolean { - type ActorCheckerEntry = { CheckedRequest?: { actorId: string } }; - const actorChecker = request.actorsChecker as ActorCheckerEntry[]; - - if (!Array.isArray(actorChecker)) { - return false; - } - - const matchingEntry = actorChecker.find( - (entry) => String(entry?.CheckedRequest?.actorId) === actorSub, + private isBlameForClient(r: any, client: Types.ObjectId): boolean { + const idStr = String(client); + return (r?.parties || []).some( + (p) => String(p?.person?.clientId || "") === idStr, ); - - return !!matchingEntry; } - /** - * Filters claim requests using the same logic as expert-claim service - * to ensure consistency between endpoints - */ - private async filterClaimRequestsForExpert( - requests: any[], - actor: any, - ): Promise { - const filteredRequests = []; + private isClaimForClient(r: any, client: Types.ObjectId): boolean { + return String(r?.owner?.userClientKey || "") === String(client); + } - for (const r of requests) { - // For expert-initiated blame files, only show to the initiating expert - if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) { - if (String(r.blameFile.initiatedBy) !== actor.sub) { - continue; // Skip if not the initiating expert - } - // Expert-initiated claim files are always visible to the initiating expert - filteredRequests.push(r); - continue; - } - - const client = await this.clientDbService.findOne({ - _id: r.userClientKey, - }); - - if (!client) { - this.logger.warn( - `Client not found for claim request with ID: ${r._id}. Skipping.`, - ); - continue; - } - - const specialHandlingStatuses = [ - ReqClaimStatus.CheckAgain, - ReqClaimStatus.ReviewRequest, - ReqClaimStatus.PendingFactorValidation, - ]; - - const requiresSpecificActorCheck = specialHandlingStatuses.includes( - r.claimStatus, - ); - - if (requiresSpecificActorCheck) { - if (this.wasHandledByActor(r, actor.sub)) { - filteredRequests.push(r); - } - } else { - if (this.isVisibleToClientType(client, actor)) { - filteredRequests.push(r); - } - } + private countBlameByCaseStatus( + blames: any[], + clientId: Types.ObjectId, + ): Record { + const out: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) { + out[s] = 0; } - - return filteredRequests; + for (const r of blames) { + if (!this.isBlameForClient(r, clientId)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; } - async getAllRequestsCountByRole(role: string, client: string, actor?: any) { + private countClaimByCaseStatus( + claims: any[], + clientId: Types.ObjectId, + ): Record { + const out: Record = { all: 0 }; + for (const s of Object.values(ClaimCaseStatus)) { + out[s] = 0; + } + for (const r of claims) { + if (!this.isClaimForClient(r, clientId)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + async getAllRequestsCountByRole(role: string, client: string | undefined) { + const clientId = this.clientObjectId(client); + const [blames, claims] = await Promise.all([ + this.blameRequestDbService.find({}, { lean: true }), + this.claimCaseDbService.find({}, { lean: true }), + ]); + if (role === "expert") { - const statuses = Object.values(ReqBlameStatus); - const data: Record = { all: 0 }; - - for (const status of statuses) { - const filter = { - blameStatus: status, - $or: [ - { - "firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId( - client, - ), - }, - // { - // "secondPartyDetails.secondPartyClient.clientId": - // new Types.ObjectId(client), - // }, - ], - }; - - const count = - await this.requestManagementDbService.countByFilter(filter); - data[status] = count; - data.all += count; - } - - return data; + return this.countBlameByCaseStatus(blames as any[], clientId); } if (role === "damage_expert") { - const statuses = Object.values(ReqClaimStatus); - const data: Record = { all: 0 }; - - // For damage_expert, we need to apply the same filtering as expert-claim service - if (actor) { - // Fetch all requests with the statuses that expert-claim service shows - // This matches the statuses in getClaimRequestsListForExpert - const relevantStatuses = [ - ReqClaimStatus.UnChecked, - ReqClaimStatus.ReviewRequest, - ReqClaimStatus.CheckAgain, - ReqClaimStatus.CloseRequest, - ReqClaimStatus.InPersonVisit, - ReqClaimStatus.CheckedRequest, - ReqClaimStatus.PendingFactorValidation, - ]; - - // Fetch all requests with relevant statuses (matching expert-claim query) - const allRequests = - await this.claimRequestManagementDbService.findAllByStatus({ - claimStatus: { $in: relevantStatuses }, - }); - - // Filter requests using the same logic as expert-claim service - const filteredRequests = - await this.filterClaimRequestsForExpert(allRequests, actor); - - // Count by status from filtered results - for (const status of statuses) { - const count = filteredRequests.filter( - (r) => r.claimStatus === status, - ).length; - data[status] = count; - data.all += count; - } - } else { - // Fallback to simple count if actor not provided (shouldn't happen for damage_expert) - for (const status of statuses) { - const filter = { - claimStatus: status, - userClientKey: new Types.ObjectId(client), - }; - - const count = - await this.claimRequestManagementDbService.countByFilter(filter); - data[status] = count; - data.all += count; - } - } - - return data; + return this.countClaimByCaseStatus(claims as any[], clientId); } - // ✅ Company logic if (role === "company") { - const blameStatuses = Object.values(ReqBlameStatus); - const claimStatuses = Object.values(ReqClaimStatus); - - const blameData: Record = { all: 0 }; - const claimData: Record = { all: 0 }; - - // Only count files where this company is FIRST party in Blame - for (const status of blameStatuses) { - const filter = { - blameStatus: status, - "firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId( - client, - ), - }; - - const count = - await this.requestManagementDbService.countByFilter(filter); - blameData[status] = count; - blameData.all += count; - } - - // Claims always use `userClientKey` - for (const status of claimStatuses) { - const filter = { - claimStatus: status, - userClientKey: new Types.ObjectId(client), - }; - - const count = - await this.claimRequestManagementDbService.countByFilter(filter); - claimData[status] = count; - claimData.all += count; - } - - return { blameData, claimData }; + return { + blame: this.countBlameByCaseStatus(blames as any[], clientId), + claim: this.countClaimByCaseStatus(claims as any[], clientId), + }; } return null; } - async getAllRequestsReportCount(actor, client) { - if (actor.role === "damage_expert") { - // Pass actor to apply filtering logic for damage_expert - const data = await this.getAllRequestsCountByRole( - actor.role, - client, - actor, - ); - return new DamageExpertAllRequestsCountReportDtoRs(data); - } else if (actor.role === "expert") { - const data = await this.getAllRequestsCountByRole(actor.role, client); - return new ExpertAllRequestsCountReportDtoRs(data); - } else { - // company - const damageExpertData = await this.getAllRequestsCountByRole( - "damage_expert", - client, - ); - const expertData = await this.getAllRequestsCountByRole("expert", client); - return new CompanyAllRequestsCountReportDtoRs( - expertData, - damageExpertData, - ); + /** + * Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`. + * Company gets both blame and claim maps; expert / damage_expert get one map. + */ + async getDateFilteredRequestByStatus( + role: string, + client: string | undefined, + start: Date, + end: Date, + ) { + const clientId = this.clientObjectId(client); + const [blames, claims] = await Promise.all([ + this.blameRequestDbService.find({}, { lean: true }), + this.claimCaseDbService.find({}, { lean: true }), + ]); + + const inRange = (r: any) => { + const createdAt = new Date(r.createdAt); + return createdAt >= start && createdAt <= end; + }; + + if (role === "company") { + const blameOut: Record = { all: 0 }; + const claimOut: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) blameOut[s] = 0; + for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0; + + for (const r of blames as any[]) { + if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in blameOut) blameOut[st]++; + else blameOut[st] = (blameOut[st] ?? 0) + 1; + blameOut.all++; + } + for (const r of claims as any[]) { + if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in claimOut) claimOut[st]++; + else claimOut[st] = (claimOut[st] ?? 0) + 1; + claimOut.all++; + } + return { blame: blameOut, claim: claimOut }; } + + if (role === "expert") { + const out: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) out[s] = 0; + for (const r of blames as any[]) { + if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + if (role === "damage_expert") { + const out: Record = { all: 0 }; + for (const s of Object.values(ClaimCaseStatus)) out[s] = 0; + for (const r of claims as any[]) { + if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + throw new BadRequestException("Unsupported role for reports"); } - async getAllRequestsReportPerMonth(actor, client) { - const result = []; + async getAllRequestsReportCount(actor: any, client: string | undefined) { + requireActorClientKey(actor); + + if (actor.role === "damage_expert") { + const data = await this.getAllRequestsCountByRole(actor.role, client); + return new ClaimCaseStatusCountReportDtoRs(data as Record); + } + if (actor.role === "expert") { + const data = await this.getAllRequestsCountByRole(actor.role, client); + return new BlameCaseStatusCountReportDtoRs(data as Record); + } + const companyPayload = await this.getAllRequestsCountByRole( + "company", + client, + ); + return new CompanyAllRequestsCountReportDtoRs( + (companyPayload as { blame: Record }).blame, + (companyPayload as { claim: Record }).claim, + ); + } + + async getAllRequestsReportPerMonth(actor: any, client: string | undefined) { + requireActorClientKey(actor); + const result: any[] = []; const now = new Date(); - if (actor.role === "company") { - for (let i = 0; i < 5; i++) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date( - now.getFullYear(), - now.getMonth() - i + 1, - 0, - 23, - 59, - 59, - ); - const label = monthStart.toLocaleDateString("fa-IR", { - month: "long", - year: "numeric", - }); + for (let i = 0; i < 5; i++) { + const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthEnd = new Date( + now.getFullYear(), + now.getMonth() - i + 1, + 0, + 23, + 59, + 59, + ); + const label = monthStart.toLocaleDateString("fa-IR", { + month: "long", + year: "numeric", + }); - const blameData = await this.getDateFilteredRequestByStatus( - "expert", + let data: any; + if (actor.role === "company") { + data = await this.getDateFilteredRequestByStatus( + "company", client, monthStart, monthEnd, ); - const claimData = await this.getDateFilteredRequestByStatus( - "damage_expert", - client, - monthStart, - monthEnd, - ); - - result.unshift({ - stDate: monthStart, - enDate: monthEnd, - faLabel: label, - data: { - blame: blameData, - claim: claimData, - }, - }); - } - return result; - } else { - for (let i = 0; i < 5; i++) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date( - now.getFullYear(), - now.getMonth() - i + 1, - 0, - 23, - 59, - 59, - ); - const label = monthStart.toLocaleDateString("fa-IR", { - month: "long", - year: "numeric", - }); - const data = await this.getDateFilteredRequestByStatus( + } else { + data = await this.getDateFilteredRequestByStatus( actor.role, client, monthStart, monthEnd, ); - result.unshift({ - stDate: monthStart, - enDate: monthEnd, - faLabel: label, - data, - }); } - return result; + + result.unshift({ + stDate: monthStart, + enDate: monthEnd, + faLabel: label, + data, + }); } + return result; } - async getAllCheckedRequestsCount(actor, client) { - if (actor.role === "damage_expert" || actor.role === "expert") { - return this.getAllCheckedRequestsCountFn(actor.role, client); - } else { - const blame = await this.getAllCheckedRequestsCountFn("expert", client); - const claim = await this.getAllCheckedRequestsCountFn( - "damage_expert", - client, - ); - return { blame, claim }; - } + /** Same aggregates as `GET /reports/report/requests` (native status keys). */ + async getAllCheckedRequestsCount(actor: any, client: string | undefined) { + return this.getAllRequestsReportCount(actor, client); } }