diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 91ba7d4..cb6d410 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -215,6 +215,9 @@ export class ClaimResendRequest { /** Damage expert profile when resend was requested (`damage-expert` collection). */ @Prop({ type: ExpertProfileSnapshotSchema }) expertProfileSnapshot?: ExpertProfileSnapshot; + + @Prop({ type: Types.ObjectId }) + requestedByExpertId?: Types.ObjectId; } export const ClaimResendRequestSchema = SchemaFactory.createForClass(ClaimResendRequest); diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 11e24d5..0b4405b 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -16,6 +16,11 @@ import { blameCaseStatusToReportBucket, initialBlameExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; +import { + blameDocInExpertPortfolio, + expertPortfolioFileIdsFromActivityEvents, + objectIdsFromStringSet, +} from "src/helpers/expert-portfolio"; 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 { @@ -222,37 +227,54 @@ export class ExpertBlameService { * Does NOT show requests decided by other experts. */ /** - * V2: Count blame cases for this expert’s tenant, grouped for dashboard: - * `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged. + * V2: Count blame cases in this field expert’s portfolio, grouped for dashboard. + * Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file. */ async getStatusReportBucketsV2(actor: any): Promise> { requireActorClientKey(actor); - const rows = await this.blameRequestDbService.find({}, { lean: true }); + const expertId = String(actor.sub); + const expertOid = new Types.ObjectId(expertId); + + const activityEvents = await this.expertFileActivityDbService.findByExpert( + expertId, + ExpertFileKind.BLAME, + ); + const activityFileIds = + expertPortfolioFileIdsFromActivityEvents(activityEvents); + + const orClauses: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + { "expert.decision.decidedByExpertId": expertOid }, + { "expert.resend.requestedByExpertId": expertOid }, + { "workflow.lockedBy.actorId": expertOid }, + ]; + const activityOids = objectIdsFromStringSet(activityFileIds); + if (activityOids.length > 0) { + orClauses.push({ _id: { $in: activityOids } }); + } + + const rows = await this.blameRequestDbService.find( + { + blameStatus: BlameStatus.DISAGREEMENT, + $or: orClauses, + }, + { lean: true }, + ); + const buckets = initialBlameExpertReportBuckets(); + const seen = new Set(); for (const doc of rows as Record[]) { - if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue; - const st = String(doc.status ?? ""); - const key = blameCaseStatusToReportBucket(st); + const id = String(doc._id ?? ""); + if (seen.has(id)) continue; + if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue; + seen.add(id); + const key = blameCaseStatusToReportBucket(String(doc.status ?? "")); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } return buckets; } - /** Tenant + expert-initiated visibility (same as list, without queue filters). */ - private blameDocIncludedInExpertTenantReport( - doc: Record, - actor: { sub: string; clientKey?: string }, - ): boolean { - if (!blameCaseAccessibleToExpert(doc, actor)) { - return false; - } - if (doc.expertInitiated && doc.initiatedByFieldExpertId) { - return String(doc.initiatedByFieldExpertId) === String(actor.sub); - } - return true; - } - async findAllV2(actor: any): Promise { try { requireActorClientKey(actor); @@ -1217,15 +1239,23 @@ export class ExpertBlameService { ); } + const resendTenantId = + String(request.parties?.[0]?.person?.clientId ?? "") || + String(request.parties?.[1]?.person?.clientId ?? ""); await this.recordBlameExpertActivity({ expertId: String(actorId), requestId: String(requestId), eventType: ExpertFileActivityType.UNCHECKED, - tenantId: - String(request.parties?.[0]?.person?.clientId ?? "") || - String(request.parties?.[1]?.person?.clientId ?? ""), + tenantId: resendTenantId, idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`, }); + await this.recordBlameExpertActivity({ + expertId: String(actorId), + requestId: String(requestId), + eventType: ExpertFileActivityType.HANDLED, + tenantId: resendTenantId, + idempotencyKey: `blame:${requestId}:handled:resend:${actorId}`, + }); await this.requestManagementService.applyLinkedClaimsBlameResendStarted( requestId, diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 2691b9f..17fda8f 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -40,9 +40,9 @@ export class ExpertBlameV2Controller { @Get("report/status-counts") @ApiOperation({ - summary: "Count blame cases by grouped status bucket (tenant)", + summary: "Count blame cases by grouped status bucket (this field expert)", description: - "IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.", + "Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.", }) async getStatusReportBucketsV2(@CurrentUser() actor: any) { try { diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 003572f..1422a76 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -45,6 +45,11 @@ import { claimCaseStatusToReportBucket, initialClaimExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; +import { + claimDocInExpertPortfolio, + expertPortfolioFileIdsFromActivityEvents, + objectIdsFromStringSet, +} from "src/helpers/expert-portfolio"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; @@ -2570,6 +2575,7 @@ export class ExpertClaimService { resendDescription: desc || undefined, resendDocuments: uniqueDocs, resendCarParts: normalizedParts, + requestedByExpertId: new Types.ObjectId(actor.sub), ...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }), }, }, @@ -2606,6 +2612,14 @@ export class ExpertClaimService { }); } + await this.recordClaimExpertActivity({ + expertId: String(actor.sub), + tenantId: this.claimActivityTenantId(claim, actor), + claimId: String(claimRequestId), + eventType: ExpertFileActivityType.HANDLED, + idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`, + }); + return { claimRequestId, status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, @@ -2972,18 +2986,55 @@ export class ExpertClaimService { } /** - * V2: Count claim cases for this damage expert’s tenant, grouped for dashboard: - * `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES); - * other {@link ClaimCaseStatus} keys unchanged. + * V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard. + * Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities. + * Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file. */ async getStatusReportBucketsV2(actor: any): Promise> { const clientKey = requireActorClientKey(actor); - const rows = await this.claimCaseDbService.find({}, { lean: true }); + const expertId = String(actor.sub); + const expertOid = new Types.ObjectId(expertId); + + const activityEvents = await this.expertFileActivityDbService.findByExpert( + expertId, + ExpertFileKind.CLAIM, + ); + const activityFileIds = + expertPortfolioFileIdsFromActivityEvents(activityEvents); + + const orClauses: Record[] = [ + { "workflow.lockedBy.actorId": expertOid }, + { "evaluation.damageExpertReply.actorDetail.actorId": expertOid }, + { "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid }, + { "evaluation.damageExpertResend.requestedByExpertId": expertOid }, + ]; + const activityOids = objectIdsFromStringSet(activityFileIds); + if (activityOids.length > 0) { + orClauses.push({ _id: { $in: activityOids } }); + } + + const rows = + orClauses.length === 0 + ? [] + : await this.claimCaseDbService.find({ $or: orClauses }, { lean: true }); + const buckets = initialClaimExpertReportBuckets(); + const seen = new Set(); for (const doc of rows as Record[]) { - if (!claimCaseTouchesClient(doc, clientKey)) continue; - const st = String(doc.status ?? ""); - const key = claimCaseStatusToReportBucket(st); + const id = String(doc._id ?? ""); + if (seen.has(id)) continue; + if ( + !claimDocInExpertPortfolio( + doc, + expertId, + activityFileIds, + clientKey, + ) + ) { + continue; + } + seen.add(id); + const key = claimCaseStatusToReportBucket(String(doc.status ?? "")); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 1ae55e7..58f2655 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -43,9 +43,9 @@ export class ExpertClaimV2Controller { @Get("report/status-counts") @ApiOperation({ - summary: "Count claim cases by grouped status bucket (tenant)", + summary: "Count claim cases by grouped status bucket (this damage expert)", description: - "IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.", + "Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.", }) async getStatusReportBucketsV2(@CurrentUser() actor: any) { return await this.expertClaimService.getStatusReportBucketsV2(actor); diff --git a/src/helpers/expert-portfolio.spec.ts b/src/helpers/expert-portfolio.spec.ts new file mode 100644 index 0000000..dc3051e --- /dev/null +++ b/src/helpers/expert-portfolio.spec.ts @@ -0,0 +1,73 @@ +import { Types } from "mongoose"; +import { + ExpertFileActivityType, +} from "src/users/entities/schema/expert-file-activity.schema"; +import { expertPortfolioFileIdsFromActivityEvents } from "./expert-portfolio"; + +describe("expertPortfolioFileIdsFromActivityEvents", () => { + const fid = new Types.ObjectId(); + + it("includes handled files after HANDLED", () => { + const ids = expertPortfolioFileIdsFromActivityEvents([ + { + fileId: fid, + eventType: ExpertFileActivityType.CHECKED, + occurredAt: new Date("2026-01-01T10:00:00Z"), + }, + { + fileId: fid, + eventType: ExpertFileActivityType.HANDLED, + occurredAt: new Date("2026-01-01T11:00:00Z"), + }, + ]); + expect(ids.has(String(fid))).toBe(true); + }); + + it("includes currently checked files", () => { + const ids = expertPortfolioFileIdsFromActivityEvents([ + { + fileId: fid, + eventType: ExpertFileActivityType.CHECKED, + occurredAt: new Date("2026-01-01T10:00:00Z"), + }, + ]); + expect(ids.has(String(fid))).toBe(true); + }); + + it("keeps handled files after a later UNCHECKED (e.g. resend unlock)", () => { + const ids = expertPortfolioFileIdsFromActivityEvents([ + { + fileId: fid, + eventType: ExpertFileActivityType.CHECKED, + occurredAt: new Date("2026-01-01T10:00:00Z"), + }, + { + fileId: fid, + eventType: ExpertFileActivityType.HANDLED, + occurredAt: new Date("2026-01-01T10:30:00Z"), + }, + { + fileId: fid, + eventType: ExpertFileActivityType.UNCHECKED, + occurredAt: new Date("2026-01-01T11:00:00Z"), + }, + ]); + expect(ids.has(String(fid))).toBe(true); + }); + + it("excludes files after UNCHECKED without HANDLED", () => { + const ids = expertPortfolioFileIdsFromActivityEvents([ + { + fileId: fid, + eventType: ExpertFileActivityType.CHECKED, + occurredAt: new Date("2026-01-01T10:00:00Z"), + }, + { + fileId: fid, + eventType: ExpertFileActivityType.UNCHECKED, + occurredAt: new Date("2026-01-01T10:30:00Z"), + }, + ]); + expect(ids.has(String(fid))).toBe(false); + }); +}); diff --git a/src/helpers/expert-portfolio.ts b/src/helpers/expert-portfolio.ts new file mode 100644 index 0000000..d67dfef --- /dev/null +++ b/src/helpers/expert-portfolio.ts @@ -0,0 +1,113 @@ +import { Types } from "mongoose"; +import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema"; +import { + blameCaseAccessibleToExpert, + claimCaseTouchesClient, +} from "src/helpers/tenant-scope"; + +export interface ExpertFileActivityEventRow { + fileId: Types.ObjectId | string; + eventType: ExpertFileActivityType; + occurredAt: Date; +} + +/** + * Distinct file ids where the expert is actively checking (CHECKED, not UNCHECKED) + * or has handled the file (HANDLED), replayed in chronological order. + */ +export function expertPortfolioFileIdsFromActivityEvents( + events: ExpertFileActivityEventRow[], +): Set { + const sorted = [...events].sort( + (a, b) => + a.occurredAt.getTime() - b.occurredAt.getTime() || + String(a.fileId).localeCompare(String(b.fileId)), + ); + const stateByFile = new Map(); + + for (const e of sorted) { + const fid = String(e.fileId); + const prev = stateByFile.get(fid) ?? { 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; + } + stateByFile.set(fid, prev); + } + + const ids = new Set(); + for (const [fid, st] of stateByFile) { + if (st.handled || st.checked) ids.add(fid); + } + return ids; +} + +export function objectIdsFromStringSet(ids: Set): Types.ObjectId[] { + return [...ids] + .filter((id) => Types.ObjectId.isValid(id)) + .map((id) => new Types.ObjectId(id)); +} + +/** Damage-expert claim portfolio: assigned (lock/check) or substantive work on the file. */ +export function claimDocInExpertPortfolio( + doc: Record, + expertId: string, + activityFileIds: Set, + clientKey: string, +): boolean { + if (!claimCaseTouchesClient(doc, clientKey)) return false; + const id = String(doc._id ?? ""); + if (activityFileIds.has(id)) return true; + + const lockedBy = (doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined) + ?.lockedBy?.actorId; + if (lockedBy != null && String(lockedBy) === String(expertId)) return true; + + const evaluation = doc.evaluation as Record | undefined; + for (const key of ["damageExpertReply", "damageExpertReplyFinal"] as const) { + const reply = evaluation?.[key] as + | { actorDetail?: { actorId?: unknown } } + | undefined; + const actorId = reply?.actorDetail?.actorId; + if (actorId != null && String(actorId) === String(expertId)) return true; + } + const resendBy = ( + evaluation?.damageExpertResend as { requestedByExpertId?: unknown } | undefined + )?.requestedByExpertId; + if (resendBy != null && String(resendBy) === String(expertId)) return true; + return false; +} + +/** Field-expert blame portfolio: initiated, decided, locked, or file-activity touch. */ +export function blameDocInExpertPortfolio( + doc: Record, + actor: { sub: string; clientKey?: string }, + activityFileIds: Set, +): boolean { + if (!blameCaseAccessibleToExpert(doc, actor)) return false; + const expertId = String(actor.sub); + const id = String(doc._id ?? ""); + if (activityFileIds.has(id)) return true; + + if (doc.expertInitiated && doc.initiatedByFieldExpertId) { + return String(doc.initiatedByFieldExpertId) === expertId; + } + + const expert = doc.expert as Record | undefined; + const decidedBy = (expert?.decision as { decidedByExpertId?: unknown } | undefined) + ?.decidedByExpertId; + if (decidedBy != null && String(decidedBy) === expertId) return true; + + const lockedBy = + (expert?.resend as { requestedByExpertId?: unknown } | undefined) + ?.requestedByExpertId ?? + (doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)?.lockedBy + ?.actorId; + if (lockedBy != null && String(lockedBy) === expertId) return true; + + return false; +} diff --git a/src/users/entities/db-service/expert-file-activity.db.service.ts b/src/users/entities/db-service/expert-file-activity.db.service.ts index 97decd3..9057691 100644 --- a/src/users/entities/db-service/expert-file-activity.db.service.ts +++ b/src/users/entities/db-service/expert-file-activity.db.service.ts @@ -71,6 +71,29 @@ export class ExpertFileActivityDbService { .lean(); } + /** Activity rows for one expert and file kind (claim or blame panel). */ + async findByExpert( + expertId: string | Types.ObjectId, + fileType: ExpertFileKind, + ): Promise< + Array<{ + fileId: Types.ObjectId; + eventType: ExpertFileActivityType; + occurredAt: Date; + }> + > { + return this.activityModel + .find( + { + expertId: new Types.ObjectId(String(expertId)), + fileType, + }, + { fileId: 1, eventType: 1, occurredAt: 1 }, + ) + .sort({ occurredAt: 1, _id: 1 }) + .lean(); + } + /** All file-activity rows for an insurer tenant (blame + claim experts). */ async findByTenant(tenantId: string | Types.ObjectId): Promise< Array<{