From 7ff3e9fd10e4b36415ec7c3e25b87484b2e79482 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 18 May 2026 10:06:14 +0330 Subject: [PATCH 1/2] YARA-850 --- src/expert-claim/dto/claim-detail-v2.dto.ts | 6 ++ src/expert-claim/dto/expert-claim-v2.dto.ts | 60 ++----------------- src/expert-claim/expert-claim.service.ts | 24 +++++++- .../expert-claim.v2.controller.ts | 2 +- .../outer-damage-parts-resolve.spec.ts | 37 ++++++++++++ src/helpers/outer-damage-parts.ts | 42 +++++++++++++ 6 files changed, 113 insertions(+), 58 deletions(-) create mode 100644 src/helpers/outer-damage-parts-resolve.spec.ts diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 77f03e2..77bc7c8 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -93,6 +93,11 @@ export class ClaimDetailV2ResponseDto { type: 'object', properties: { index: { type: 'number' }, + partId: { + type: 'string', + description: + 'Send this in PUT reply/submit parts[] — stable id (`id:{catalogId}` or `{side}|{name}`)', + }, id: { type: 'number', nullable: true }, name: { type: 'string' }, side: { type: 'string' }, @@ -104,6 +109,7 @@ export class ClaimDetailV2ResponseDto { }) damagedParts?: Array<{ index: number; + partId: string; id?: number | null; name: string; side: string; diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 16c3ca8..fa8a15b 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -7,59 +7,12 @@ import { IsOptional, ValidateNested, IsEnum, - IsInt, } from 'class-validator'; import { Type } from 'class-transformer'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; -/** - * Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows. - * Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }` - * (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`. - */ -export class ExpertReplyCarPartDamageV2Dto { - @ApiPropertyOptional({ description: 'Catalog id when known' }) - @IsOptional() - @IsInt() - id?: number; - - @ApiPropertyOptional({ - description: 'Side-agnostic catalog segment, e.g. backfender', - example: 'backfender', - }) - @IsOptional() - @IsString() - name?: string; - - @ApiPropertyOptional({ example: 'left' }) - @IsOptional() - @IsString() - side?: string; - - @ApiPropertyOptional({ - description: - 'Farsi label; optional when server can resolve from catalog + vehicle.carType', - }) - @IsOptional() - @IsString() - label_fa?: string; - - @ApiPropertyOptional({ example: 'left_backfender' }) - @IsOptional() - @IsString() - catalogKey?: string; - - @ApiPropertyOptional({ - description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender', - example: 'backFender', - }) - @IsOptional() - @IsString() - part?: string; -} - /** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */ export class DaghiDetailsV2Dto { @ApiProperty({ @@ -86,16 +39,15 @@ export class DaghiDetailsV2Dto { } export class PartPricingV2Dto { - @ApiProperty({ example: 'part-001' }) + @ApiProperty({ + example: '12', + description: + 'Stable part id from GET claim detail `damagedParts[].partId` or `selectedParts` identity (`id:{catalogId}` or `{side}|{name}`). Required.', + }) @IsString() @IsNotEmpty() partId: string; - @ApiProperty({ type: ExpertReplyCarPartDamageV2Dto }) - @ValidateNested() - @Type(() => ExpertReplyCarPartDamageV2Dto) - carPartDamage: ExpertReplyCarPartDamageV2Dto; - @ApiProperty({ example: 'Minor' }) @IsString() typeOfDamage: string; @@ -135,7 +87,7 @@ export class SubmitExpertReplyV2Dto { @ApiProperty({ type: [PartPricingV2Dto], description: - "Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.", + "One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.", }) @IsArray() @ValidateNested({ each: true }) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index d912e2d..003572f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -84,6 +84,7 @@ import { normalizeDamageSelectedParts, partIdentityKey, partIdentityKeyFromCarPartDamage, + resolveSelectedPartByPartId, } from "src/helpers/outer-damage-parts"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; @@ -2698,13 +2699,29 @@ export class ExpertClaimService { const processedParts = daghiNormalized.map((p) => { let carPartDamage: Record; try { + const selected = resolveSelectedPartByPartId( + p.partId, + ownerSelectedParts, + ); + if (!selected) { + throw new BadRequestException( + `Unknown partId "${p.partId}". Use partId from claim detail damagedParts (after editing parts on the claim).`, + ); + } carPartDamage = normalizeCarPartDamageForExpertReply( - p.carPartDamage as unknown, + { + id: selected.id, + name: selected.name, + side: selected.side, + label_fa: selected.label_fa, + ...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}), + }, carTypeSubmit, ); } catch (err) { + if (err instanceof BadRequestException) throw err; throw new BadRequestException( - `Invalid carPartDamage for part ${p.partId}: ${ + `Invalid part ${p.partId}: ${ err instanceof Error ? err.message : String(err) }`, ); @@ -2713,7 +2730,7 @@ export class ExpertClaimService { const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage); if (!identityKey) { throw new BadRequestException( - `Invalid carPartDamage for part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`, + `Invalid part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`, ); } @@ -3537,6 +3554,7 @@ export class ExpertClaimService { ) as { url?: string; path?: string } | undefined; return { index, + partId: partIdentityKey(sp), id: sp.id, name: sp.name, side: sp.side, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 3f62add..1ae55e7 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -132,7 +132,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Submit expert damage assessment reply", description: - "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + + "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + "**Frontend routing by `ClaimCaseStatus` (`status`):**\n" + "- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" + "- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" + diff --git a/src/helpers/outer-damage-parts-resolve.spec.ts b/src/helpers/outer-damage-parts-resolve.spec.ts new file mode 100644 index 0000000..7f933e6 --- /dev/null +++ b/src/helpers/outer-damage-parts-resolve.spec.ts @@ -0,0 +1,37 @@ +import { + partIdentityKey, + resolveSelectedPartByPartId, + type DamageSelectedPartV2, +} from "./outer-damage-parts"; + +describe("resolveSelectedPartByPartId", () => { + const selected: DamageSelectedPartV2[] = [ + { + id: 12, + name: "backfender", + side: "left", + label_fa: "گلگیر", + catalogKey: "left_backfender", + }, + { + id: null, + name: "hood", + side: "front", + label_fa: "کاپوت", + }, + ]; + + it("resolves by partIdentityKey", () => { + expect( + resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected), + ).toBe(selected[0]); + }); + + it("resolves by id:N catalog prefix", () => { + expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]); + }); + + it("resolves by index:N", () => { + expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]); + }); +}); diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts index 27dd684..3a94a51 100644 --- a/src/helpers/outer-damage-parts.ts +++ b/src/helpers/outer-damage-parts.ts @@ -298,6 +298,48 @@ export function partIdentityKey(p: DamageSelectedPartV2): string { return `${p.side}|${p.name}`; } +/** + * Resolve a client `partId` from claim detail / expert reply to a selected part row. + * Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id + * when unique, or `index:N` for the Nth entry in `selectedParts`. + */ +export function resolveSelectedPartByPartId( + partId: string, + selected: DamageSelectedPartV2[], +): DamageSelectedPartV2 | undefined { + const raw = String(partId ?? "").trim(); + if (!raw || !selected.length) return undefined; + + for (const p of selected) { + if (partIdentityKey(p) === raw) return p; + } + + const idColon = raw.match(/^id:(\d+)$/i); + if (idColon) { + const id = Number(idColon[1]); + return selected.find((p) => p.id === id); + } + + const indexColon = raw.match(/^index:(\d+)$/i); + if (indexColon) { + const i = Number(indexColon[1]); + if (Number.isInteger(i) && i >= 0 && i < selected.length) { + return selected[i]; + } + } + + if (/^\d+$/.test(raw)) { + const n = Number(raw); + const byCatalogId = selected.filter((p) => p.id === n); + if (byCatalogId.length === 1) return byCatalogId[0]; + if (Number.isInteger(n) && n >= 0 && n < selected.length) { + return selected[n]; + } + } + + return undefined; +} + /** * Same canonical identity as `partIdentityKey`, but derived directly from a * unified `carPartDamage` snapshot (the shape we persist on From e1954cdb37a1a1ac8ed99720d65ed8cadd052802 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 18 May 2026 11:00:53 +0330 Subject: [PATCH 2/2] YARA-885 --- .../schema/claim-case.evaluation.schema.ts | 3 + src/expert-blame/expert-blame.service.ts | 76 ++++++++---- .../expert-blame.v2.controller.ts | 4 +- src/expert-claim/expert-claim.service.ts | 65 ++++++++-- .../expert-claim.v2.controller.ts | 4 +- src/helpers/expert-portfolio.spec.ts | 73 +++++++++++ src/helpers/expert-portfolio.ts | 113 ++++++++++++++++++ .../expert-file-activity.db.service.ts | 23 ++++ 8 files changed, 327 insertions(+), 34 deletions(-) create mode 100644 src/helpers/expert-portfolio.spec.ts create mode 100644 src/helpers/expert-portfolio.ts 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<{