From d8f7766f10874adc1b16054762f7b0c7439446a6 Mon Sep 17 00:00:00 2001 From: Sepehr Yahyaee Date: Thu, 18 Jun 2026 13:31:56 +0330 Subject: [PATCH 1/3] Fixed Lock for Field expert + user view of files --- package-lock.json | 191 ++++-------------- .../claim-request-management.service.ts | 40 +++- src/expert-blame/expert-blame.service.ts | 75 ++++++- .../expert-blame.v2.controller.ts | 2 +- src/expert-claim/expert-claim.service.ts | 104 +++++++--- src/helpers/iran-mobile.ts | 25 +++ src/helpers/tenant-scope.ts | 14 ++ .../request-management.service.ts | 111 +++++----- 8 files changed, 328 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7abe52..6915688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,24 +179,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -204,22 +186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -275,24 +241,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -300,22 +248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -1075,9 +1007,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", - "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", "license": "BSD-3-Clause", "engines": { "node": ">=14.0.0" @@ -2175,6 +2107,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2192,6 +2127,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2209,6 +2147,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2226,6 +2167,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2243,6 +2187,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2905,24 +2852,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2930,22 +2859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -3340,6 +3253,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3357,6 +3273,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3374,6 +3293,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3391,6 +3313,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5794,24 +5719,6 @@ "dev": true, "license": "MIT" }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -7254,16 +7161,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7711,9 +7618,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8810,9 +8717,9 @@ } }, "node_modules/joi": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", - "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz", + "integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -10396,22 +10303,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index e7afe2f..d50834f 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -59,6 +59,7 @@ import { } from "./dto/my-claims-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { partyPersonMatchesUser } from "src/helpers/iran-mobile"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; @@ -4179,6 +4180,26 @@ export class ClaimRequestManagementService { return; } + if ( + (!actor?.role || actor.role === RoleEnum.USER) && + claim.blameRequestId + ) { + const blame = await this.blameRequestDbService.findById( + claim.blameRequestId.toString(), + ); + const isParty = + Array.isArray(blame?.parties) && + blame.parties.some((p: any) => + partyPersonMatchesUser(p?.person, { + sub: currentUserId, + username: (actor as { username?: string })?.username, + }), + ); + if (isParty) { + return; + } + } + if (actor?.role === RoleEnum.FIELD_EXPERT) { if (claim.initiatedByFieldExpertId?.toString() === currentUserId) { return; @@ -6603,8 +6624,25 @@ export class ClaimRequestManagementService { { lean: true }, ); } else { + const partyBlameIds = await this.blameRequestDbService + .find( + { + $or: [ + { "parties.person.userId": new Types.ObjectId(currentUserId) }, + ], + }, + { select: "_id", lean: true }, + ) + .then((docs) => docs.map((d) => (d as any)._id)); claims = await this.claimCaseDbService.find( - { "owner.userId": new Types.ObjectId(currentUserId) }, + { + $or: [ + { "owner.userId": new Types.ObjectId(currentUserId) }, + ...(partyBlameIds.length + ? [{ blameRequestId: { $in: partyBlameIds } }] + : []), + ], + }, { lean: true }, ); } diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 77d8730..6541d88 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -11,6 +11,7 @@ import { import { assertBlameCaseForExpertTenant, blameCaseAccessibleToExpert, + blameCaseInitiatedByFieldExpert, requireActorClientKey, } from "src/helpers/tenant-scope"; import { @@ -436,8 +437,9 @@ export class ExpertBlameService { } /** - * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files only. - * IN_PERSON flows are AGREED and handled outside this panel; completed blames appear in expert-claim. + * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files. + * Covers LINK flows (parties complete via link → WAITING_FOR_EXPERT) and in-progress + * disputes while parties are still filling. IN_PERSON AGREED blames use expert-claim instead. */ private async getFieldExpertBlameListV2( actor: { sub: string }, @@ -448,9 +450,25 @@ export class ExpertBlameService { const visibleCases = (await this.blameRequestDbService.find( { - blameStatus: BlameStatus.DISAGREEMENT, expertInitiated: true, initiatedByFieldExpertId: expertOid, + blameStatus: BlameStatus.DISAGREEMENT, + type: { $ne: BlameRequestType.CAR_BODY }, + $or: [ + { status: CaseStatus.WAITING_FOR_EXPERT }, + { + status: { + $in: [ + CaseStatus.OPEN, + CaseStatus.WAITING_FOR_SECOND_PARTY, + CaseStatus.WAITING_FOR_DOCUMENT_RESEND, + ], + }, + }, + { "workflow.lockedBy.actorId": expertOid }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + { "expert.decision.decidedByExpertId": expertOid }, + ], }, { lean: true }, )) as Record[]; @@ -966,6 +984,8 @@ export class ExpertBlameService { ); } + const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor); + const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId ? String(decision.decidedByExpertId) @@ -978,6 +998,7 @@ export class ExpertBlameService { (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", ); + if (!isFieldExpertOwner) { // Access gates — must satisfy at least one bucket const isAvailable = doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId; @@ -1002,6 +1023,7 @@ export class ExpertBlameService { "You do not have permission to view this request.", ); } + } // Build evidence URLs const parties = Array.isArray(doc.parties) ? doc.parties : []; @@ -1158,7 +1180,12 @@ export class ExpertBlameService { */ async assignBlameCaseForReviewV2( requestId: string, - actor: { sub: string; fullName?: string; clientKey?: string }, + actor: { + sub: string; + fullName?: string; + clientKey?: string; + role?: string; + }, ): Promise { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); @@ -1169,6 +1196,8 @@ export class ExpertBlameService { assertBlameCaseForExpertTenant(request, actor); + const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor); + if (request.type === BlameRequestType.CAR_BODY) { throw new BadRequestException({ success: false, @@ -1177,9 +1206,17 @@ export class ExpertBlameService { }); } + if (request.blameStatus !== BlameStatus.DISAGREEMENT) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request is not available for expert review", + }); + } + if ( - request.status !== CaseStatus.WAITING_FOR_EXPERT || - request.blameStatus !== BlameStatus.DISAGREEMENT + !isFieldExpertOwner && + request.status !== CaseStatus.WAITING_FOR_EXPERT ) { throw new BadRequestException({ success: false, @@ -1188,6 +1225,26 @@ export class ExpertBlameService { }); } + if (isFieldExpertOwner && request.expert?.decision) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "This request already has an expert decision", + }); + } + + if ( + isFieldExpertOwner && + request.status !== CaseStatus.WAITING_FOR_EXPERT + ) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: + "Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).", + }); + } + if ( request.expertInitiated && request.initiatedByFieldExpertId && @@ -1272,7 +1329,6 @@ export class ExpertBlameService { const assignFilter: Record = { _id: new Types.ObjectId(requestId), - status: CaseStatus.WAITING_FOR_EXPERT, blameStatus: BlameStatus.DISAGREEMENT, $and: [ { @@ -1293,8 +1349,11 @@ export class ExpertBlameService { ], }; - if (request.expertInitiated) { + if (isFieldExpertOwner) { assignFilter.initiatedByFieldExpertId = expertOid; + assignFilter.status = CaseStatus.WAITING_FOR_EXPERT; + } else { + assignFilter.status = CaseStatus.WAITING_FOR_EXPERT; } const updated = await this.blameRequestDbService.findOneAndUpdate( diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 1fc6911..864c689 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -43,7 +43,7 @@ export class ExpertBlameV2Controller { summary: "List blame cases for expert review (V2)", description: "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + - "Field experts (`field_expert`): only **DISAGREEMENT** files they initiated that need expert review (e.g. LINK disputes). " + + "Field experts (`field_expert`): expert-initiated **DISAGREEMENT** files — including **LINK** flows where parties complete via link and the case moves to `WAITING_FOR_EXPERT` for your review. " + "IN_PERSON expert-initiated blames are usually `AGREED` and are managed via expert-initiated / request-management APIs; after completion, use expert-claim. " + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.", }) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 594a80c..bc8853b 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2404,12 +2404,35 @@ export class ExpertClaimService { assertClaimCaseForExpertActor(claim, actor); + const isFieldExpertOwner = + (actor as any).role === RoleEnum.FIELD_EXPERT && + claimCaseInitiatedByFieldExpert(claim, actor); const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); const isDamageReviewLock = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + const isExpertReviewingReentry = + claim.status === ClaimCaseStatus.EXPERT_REVIEWING; + const isResendPending = + claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; + const isTerminalClaim = + claim.status === ClaimCaseStatus.COMPLETED || + claim.status === ClaimCaseStatus.CANCELLED; + const isFieldExpertPortfolioLock = + isFieldExpertOwner && + !isTerminalClaim && + !isDamageReviewLock && + !isFactorValidationLock && + !isExpertReviewingReentry && + !isResendPending; - if (!isDamageReviewLock && !isFactorValidationLock) { + if ( + !isDamageReviewLock && + !isFactorValidationLock && + !isExpertReviewingReentry && + !isResendPending && + !isFieldExpertPortfolioLock + ) { throw new BadRequestException({ success: false, status: "unavailable" satisfies ExpertFileAssignStatus, @@ -2457,11 +2480,15 @@ export class ExpertClaimService { const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); + const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert"; + const actorRoleLabel = isFieldExpertOwner + ? ("field_expert" as const) + : ("damage_expert" as const); const lockedByPayload = { actorId: expertOid, actorName: actor.fullName, - actorRole: "damage_expert" as const, + actorRole: actorRoleLabel, ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }; @@ -2470,34 +2497,40 @@ export class ExpertClaimService { "workflow.lockedAt": now, "workflow.expiredAt": expiredAt, "workflow.lockedBy": lockedByPayload, - "workflow.preLockQueueSnapshot": { - claimStatus: claim.claimStatus, - currentStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow?.currentStep, - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - ), - ...(claim.workflow?.nextStep != null - ? { - nextStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow.nextStep, - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ...(isDamageReviewLock || isExpertReviewingReentry || isResendPending + ? { + "workflow.preLockQueueSnapshot": { + claimStatus: claim.claimStatus, + currentStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow?.currentStep, + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, ), - } - : {}), - }, + ...(claim.workflow?.nextStep != null + ? { + nextStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow.nextStep, + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ), + } + : {}), + }, + } + : {}), $push: { history: { type: "CLAIM_ASSIGNED", actor: { actorId: expertOid, actorName: actor.fullName, - actorType: "damage_expert", + actorType, }, timestamp: now, metadata: { note: isFactorValidationLock ? "Expert assigned for factor validation review" - : "Expert assigned via review assign endpoint", + : isFieldExpertPortfolioLock + ? "Field expert portfolio lock (no status change)" + : "Expert assigned via review assign endpoint", ...(isFactorValidationLock ? { phase: "factorValidation" } : {}), }, }, @@ -2508,14 +2541,18 @@ export class ExpertClaimService { baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; } - const lockUpdate: Record = isFactorValidationLock - ? baseLockUpdate - : { - ...baseLockUpdate, - status: ClaimCaseStatus.EXPERT_REVIEWING, - claimStatus: ClaimStatus.UNDER_REVIEW, - "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - }; + const lockUpdate: Record = + isFactorValidationLock || + isExpertReviewingReentry || + isResendPending || + isFieldExpertPortfolioLock + ? baseLockUpdate + : { + ...baseLockUpdate, + status: ClaimCaseStatus.EXPERT_REVIEWING, + claimStatus: ClaimStatus.UNDER_REVIEW, + "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + }; const assignFilter: Record = { _id: new Types.ObjectId(claimRequestId), @@ -2548,6 +2585,13 @@ export class ExpertClaimService { ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, ], }; + } else if (isExpertReviewingReentry) { + assignFilter.status = ClaimCaseStatus.EXPERT_REVIEWING; + } else if (isResendPending) { + assignFilter.status = ClaimCaseStatus.WAITING_FOR_USER_RESEND; + } else if (isFieldExpertPortfolioLock) { + assignFilter.initiatedByFieldExpertId = expertOid; + assignFilter.status = claim.status; } else { assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; } @@ -2601,7 +2645,13 @@ export class ExpertClaimService { }); const ownerPhone = await this.resolveClaimOwnerPhone(updated); - if (ownerPhone) { + if ( + ownerPhone && + isDamageReviewLock && + !isFieldExpertPortfolioLock && + !isExpertReviewingReentry && + !isResendPending + ) { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( diff --git a/src/helpers/iran-mobile.ts b/src/helpers/iran-mobile.ts index 4e9e5da..9e5dde7 100644 --- a/src/helpers/iran-mobile.ts +++ b/src/helpers/iran-mobile.ts @@ -44,3 +44,28 @@ export function buildUserLookupByPhone( $or: variants.flatMap((v) => [{ username: v }, { mobile: v }]), }; } + +/** True when a blame party person row belongs to the authenticated user. */ +export function partyPersonMatchesUser( + person: + | { userId?: unknown; phoneNumber?: string } + | null + | undefined, + user: { sub?: string; username?: string } | null | undefined, +): boolean { + if (!person || !user) return false; + if ( + person.userId != null && + user.sub && + String(person.userId) === String(user.sub) + ) { + return true; + } + if (!person.phoneNumber || !user.username) return false; + const stored = + normalizeIranMobile(person.phoneNumber) ?? person.phoneNumber; + const variants = iranMobileLookupVariants(user.username); + return variants.some( + (v) => v === stored || v === person.phoneNumber, + ); +} diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts index bc0e038..2c2ae8d 100644 --- a/src/helpers/tenant-scope.ts +++ b/src/helpers/tenant-scope.ts @@ -1,4 +1,5 @@ import { BadRequestException, ForbiddenException } from "@nestjs/common"; +import { RoleEnum } from "src/Types&Enums/role.enum"; /** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */ export function requireActorClientKey(actor: { clientKey?: string }): string { @@ -64,6 +65,19 @@ export function blameCaseAccessibleToExpert( return blameCaseTouchesClient(doc, ck); } +/** True when the acting field expert created this expert-initiated blame file. */ +export function blameCaseInitiatedByFieldExpert( + doc: any, + actor: { sub: string; role?: string }, +): boolean { + return ( + actor.role === RoleEnum.FIELD_EXPERT && + !!doc?.expertInitiated && + !!doc?.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) === String(actor.sub) + ); +} + export function assertBlameCaseForExpertTenant( doc: any, actor: { sub: string; clientKey?: string }, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index e742e50..9ca5a00 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -91,6 +91,12 @@ import { MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, } from "src/helpers/blame-party-agreement-decision"; import { buildFileLink } from "src/helpers/urlCreator"; +import { + buildUserLookupByPhone, + iranMobileLookupVariants, + normalizeIranMobile, + partyPersonMatchesUser, +} from "src/helpers/iran-mobile"; @Injectable() export class RequestManagementService { @@ -209,14 +215,8 @@ export class RequestManagementService { private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) { // The initiating field-expert/registrar fills steps on behalf of parties. if (req && this.isBlameOnBehalfActor(req, user)) return; - const partyUserId = party?.person?.userId - ? String(party.person.userId) - : null; - const ok = - (partyUserId && partyUserId === String(user?.sub)) || - (party?.person?.phoneNumber && - party.person.phoneNumber === user?.username); - if (!ok) throw new ForbiddenException(errMsg); + if (partyPersonMatchesUser(party?.person, user)) return; + throw new ForbiddenException(errMsg); } /** @@ -4434,7 +4434,8 @@ export class RequestManagementService { throw new BadRequestException("First party not found on request"); const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; - req.parties[firstIdx].person.phoneNumber = phone; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; @@ -4591,7 +4592,9 @@ export class RequestManagementService { throw new BadRequestException("First party not found on request"); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.userId = firstUserId; - req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(dto.firstPartyPhoneNumber) ?? + dto.firstPartyPhoneNumber; if ( req.type === BlameRequestType.THIRD_PARTY && @@ -4617,7 +4620,9 @@ export class RequestManagementService { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = secondUserId; - req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber; + req.parties[secondIdx].person.phoneNumber = + normalizeIranMobile(dto.secondPartyPhoneNumber) ?? + dto.secondPartyPhoneNumber; } } else if ( req.type === BlameRequestType.THIRD_PARTY && @@ -4794,6 +4799,7 @@ export class RequestManagementService { const valid = await this.hashService.compare(otp, u.otp); if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); const userId = u._id as Types.ObjectId; + const canonicalPhone = normalizeIranMobile(phone) ?? phone; if (role === PartyRole.FIRST) { if (firstIdx === -1) @@ -4801,13 +4807,21 @@ export class RequestManagementService { if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.userId = userId; - req.parties[firstIdx].person.phoneNumber = phone; + req.parties[firstIdx].person.phoneNumber = canonicalPhone; } else { const firstPhone = firstIdx !== -1 ? (req.parties[firstIdx]?.person as any)?.phoneNumber : undefined; - if (firstPhone && firstPhone === phone) { + const firstVariants = firstPhone + ? iranMobileLookupVariants(firstPhone) + : []; + if ( + firstPhone && + (firstPhone === phone || + firstVariants.includes(phone) || + firstVariants.includes(canonicalPhone)) + ) { throw new BadRequestException( "Second party phone number cannot be the same as first party.", ); @@ -4816,13 +4830,13 @@ export class RequestManagementService { if (secondIdx === -1) { req.parties.push({ role: PartyRole.SECOND, - person: { userId, phoneNumber: phone }, + person: { userId, phoneNumber: canonicalPhone }, } as any); } else { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = userId; - req.parties[secondIdx].person.phoneNumber = phone; + req.parties[secondIdx].person.phoneNumber = canonicalPhone; } } @@ -5018,9 +5032,16 @@ export class RequestManagementService { user?.sub && Types.ObjectId.isValid(user.sub) ? { "parties.person.userId": new Types.ObjectId(user.sub) } : null; - const phoneFilter = user?.username - ? { "parties.person.phoneNumber": user.username } - : null; + const phoneVariants = + user?.role === RoleEnum.USER && user?.username + ? iranMobileLookupVariants(user.username) + : user?.username + ? [user.username] + : []; + const phoneFilter = + phoneVariants.length > 0 + ? { "parties.person.phoneNumber": { $in: phoneVariants } } + : null; const orConditions: Record[] = []; if (userIdFilter) orConditions.push(userIdFilter); @@ -5061,11 +5082,8 @@ export class RequestManagementService { req.registrarInitiated && String(req.initiatedByRegistrarId) === String(user.sub)); - const party = req.parties?.find( - (p: any) => - (p?.person?.userId && - String(p.person.userId) === String(user.sub)) || - (p?.person?.phoneNumber && p.person.phoneNumber === user?.username), + const party = req.parties?.find((p: any) => + partyPersonMatchesUser(p?.person, user), ); const obj = req.toObject(); @@ -5340,14 +5358,7 @@ export class RequestManagementService { String(req.initiatedByRegistrarId) === String(user?.sub); const isParty = Array.isArray(req.parties) && - req.parties.some((p: any) => { - const pid = p?.person?.userId ? String(p.person.userId) : null; - const phone = p?.person?.phoneNumber; - return ( - (pid && pid === String(user?.sub)) || - (phone && phone === user?.username) - ); - }); + req.parties.some((p: any) => partyPersonMatchesUser(p?.person, user)); if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) { throw new ForbiddenException("You do not have access to this request"); } @@ -5359,10 +5370,22 @@ export class RequestManagementService { Types.ObjectId.isValid(user.sub) ) { let updated = false; + const phoneVariants = iranMobileLookupVariants(user?.username); for (const p of req.parties || []) { - if (p?.person?.phoneNumber === user?.username && !p.person?.userId) { + const person = p?.person; + if (!person) continue; + const phoneMatch = + person.phoneNumber && + phoneVariants.some( + (v) => v === person.phoneNumber || v === normalizeIranMobile(person.phoneNumber), + ); + if (phoneMatch && !person?.userId) { p.person = p.person || {}; (p.person as any).userId = new Types.ObjectId(user.sub); + if (user?.username) { + (p.person as any).phoneNumber = + normalizeIranMobile(user.username) ?? user.username; + } updated = true; } } @@ -5384,14 +5407,9 @@ export class RequestManagementService { const visibleParties = isFieldExpertOwner || isRegistrarOwner ? allParties - : allParties.filter((p: any) => { - const pid = p?.person?.userId ? String(p.person.userId) : null; - const phone = p?.person?.phoneNumber; - return ( - (pid && pid === String(user?.sub)) || - (phone && phone === user?.username) - ); - }); + : allParties.filter((p: any) => + partyPersonMatchesUser(p?.person, user), + ); const parties = await Promise.all( visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)), ); @@ -7288,19 +7306,18 @@ export class RequestManagementService { private async getOrCreateUserByPhoneNumber( phoneNumber: string, ): Promise { - // Try to find existing user by phone number (username or mobile) - let user = await this.userDbService.findOne({ - $or: [{ username: phoneNumber }, { mobile: phoneNumber }], - }); + const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim(); + let user = await this.userDbService.findOne( + buildUserLookupByPhone(canonical), + ); if (user) { return new Types.ObjectId((user as any)._id); } - // User doesn't exist, create a new one const newUser = await this.userDbService.createUser({ - mobile: phoneNumber, - username: phoneNumber, + mobile: canonical, + username: canonical, otp: "", tokens: { token: "", From dcc3ee71dec9ca5cbb9964e33972d29c974cdb43 Mon Sep 17 00:00:00 2001 From: Sepehr Yahyaee Date: Thu, 18 Jun 2026 18:15:20 +0330 Subject: [PATCH 2/3] Fixed GET users --- package.json | 3 + .../claim-request-management.service.ts | 86 ++++++++++-------- src/expert-blame/expert-blame.service.ts | 32 ++----- .../expert-blame.v2.controller.ts | 3 +- src/expert-claim/expert-claim.service.ts | 58 ++++++++++--- .../expert-claim.v2.controller.ts | 3 +- src/helpers/party-access-queries.spec.ts | 61 +++++++++++++ src/helpers/party-access-queries.ts | 87 +++++++++++++++++++ src/helpers/tenant-scope.spec.ts | 86 ++++++++++++++++++ src/helpers/tenant-scope.ts | 32 ++++--- .../request-management.service.ts | 20 +---- 11 files changed, 368 insertions(+), 103 deletions(-) create mode 100644 src/helpers/party-access-queries.spec.ts create mode 100644 src/helpers/party-access-queries.ts create mode 100644 src/helpers/tenant-scope.spec.ts diff --git a/package.json b/package.json index 7920a6b..6bfcf3c 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index d50834f..13ee9c2 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -60,6 +60,11 @@ import { import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { partyPersonMatchesUser } from "src/helpers/iran-mobile"; +import { + buildBlamePartyAccessOrConditions, + mongoUserIdEqualityFilter, +} from "src/helpers/party-access-queries"; +import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; @@ -4201,21 +4206,16 @@ export class ClaimRequestManagementService { } if (actor?.role === RoleEnum.FIELD_EXPERT) { - if (claim.initiatedByFieldExpertId?.toString() === currentUserId) { + const blame = claim.blameRequestId + ? await this.blameRequestDbService.findById( + claim.blameRequestId.toString(), + ) + : null; + if ( + claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame) + ) { return; } - if (claim.blameRequestId) { - const blame = await this.blameRequestDbService.findById( - claim.blameRequestId.toString(), - ); - if ( - blame?.expertInitiated && - blame.initiatedByFieldExpertId && - String(blame.initiatedByFieldExpertId) === currentUserId - ) { - return; - } - } } if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) { @@ -6580,25 +6580,24 @@ export class ClaimRequestManagementService { try { let claims: any[]; if (actor?.role === RoleEnum.FIELD_EXPERT) { + const expertOid = new Types.ObjectId(currentUserId); const expertBlameIds = await this.blameRequestDbService .find( { expertInitiated: true, - initiatedByFieldExpertId: new Types.ObjectId(currentUserId), + initiatedByFieldExpertId: expertOid, }, { select: "_id", lean: true }, ) .then((docs) => docs.map((d) => (d as any)._id)); + const claimOr: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + ]; + if (expertBlameIds.length > 0) { + claimOr.push({ blameRequestId: { $in: expertBlameIds } }); + } claims = await this.claimCaseDbService.find( - { - $or: [ - { "owner.userId": new Types.ObjectId(currentUserId) }, - ...(expertBlameIds.length - ? [{ blameRequestId: { $in: expertBlameIds } }] - : []), - { initiatedByFieldExpertId: new Types.ObjectId(currentUserId) }, - ], - }, + { $or: claimOr }, { lean: true }, ); } else if (actor?.role === RoleEnum.REGISTRAR) { @@ -6624,27 +6623,38 @@ export class ClaimRequestManagementService { { lean: true }, ); } else { + const partyBlameOr = buildBlamePartyAccessOrConditions({ + sub: currentUserId, + username: (actor as { username?: string })?.username, + }); const partyBlameIds = await this.blameRequestDbService .find( - { - $or: [ - { "parties.person.userId": new Types.ObjectId(currentUserId) }, - ], - }, + partyBlameOr.length === 1 + ? (partyBlameOr[0] as any) + : ({ $or: partyBlameOr } as any), { select: "_id", lean: true }, ) .then((docs) => docs.map((d) => (d as any)._id)); - claims = await this.claimCaseDbService.find( - { - $or: [ - { "owner.userId": new Types.ObjectId(currentUserId) }, - ...(partyBlameIds.length - ? [{ blameRequestId: { $in: partyBlameIds } }] - : []), - ], - }, - { lean: true }, + + const ownerOr = mongoUserIdEqualityFilter( + "owner.userId", + currentUserId, ); + const claimOr: Record[] = []; + if (ownerOr) claimOr.push(ownerOr); + if (partyBlameIds.length) { + claimOr.push({ blameRequestId: { $in: partyBlameIds } }); + } + + claims = + claimOr.length === 0 + ? [] + : await this.claimCaseDbService.find( + claimOr.length === 1 + ? (claimOr[0] as any) + : ({ $or: claimOr } as any), + { lean: true }, + ); } const list = (claims as any[]).map((c) => ({ claimRequestId: c._id.toString(), diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 6541d88..ed3927e 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -437,9 +437,9 @@ export class ExpertBlameService { } /** - * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files. - * Covers LINK flows (parties complete via link → WAITING_FOR_EXPERT) and in-progress - * disputes while parties are still filling. IN_PERSON AGREED blames use expert-claim instead. + * Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created + * (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually + * via expert-claim after blame completion. */ private async getFieldExpertBlameListV2( actor: { sub: string }, @@ -452,23 +452,6 @@ export class ExpertBlameService { { expertInitiated: true, initiatedByFieldExpertId: expertOid, - blameStatus: BlameStatus.DISAGREEMENT, - type: { $ne: BlameRequestType.CAR_BODY }, - $or: [ - { status: CaseStatus.WAITING_FOR_EXPERT }, - { - status: { - $in: [ - CaseStatus.OPEN, - CaseStatus.WAITING_FOR_SECOND_PARTY, - CaseStatus.WAITING_FOR_DOCUMENT_RESEND, - ], - }, - }, - { "workflow.lockedBy.actorId": expertOid }, - { "workflow.assignedForReviewBy.actorId": expertOid }, - { "expert.decision.decidedByExpertId": expertOid }, - ], }, { lean: true }, )) as Record[]; @@ -967,7 +950,12 @@ export class ExpertBlameService { // Tenant check assertBlameCaseForExpertTenant(doc, actor); - if (doc.type === BlameRequestType.CAR_BODY) { + const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor); + + if ( + doc.type === BlameRequestType.CAR_BODY && + !isFieldExpertOwner + ) { throw new ForbiddenException( "CAR_BODY type requests are automatically handled and do not require expert review.", ); @@ -984,8 +972,6 @@ export class ExpertBlameService { ); } - const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor); - const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId ? String(decision.decidedByExpertId) diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 864c689..7680d87 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -43,8 +43,7 @@ export class ExpertBlameV2Controller { summary: "List blame cases for expert review (V2)", description: "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + - "Field experts (`field_expert`): expert-initiated **DISAGREEMENT** files — including **LINK** flows where parties complete via link and the case moves to `WAITING_FOR_EXPERT` for your review. " + - "IN_PERSON expert-initiated blames are usually `AGREED` and are managed via expert-initiated / request-management APIs; after completion, use expert-claim. " + + "Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.", }) async findAll( diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index bc8853b..68d9ea2 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -339,6 +339,28 @@ export class ExpertClaimService { return ev; } + /** Load linked blame when resolving field-expert ownership on a claim. */ + private async loadBlameForClaim( + claim: { blameRequestId?: unknown } | null | undefined, + ) { + const id = claim?.blameRequestId?.toString?.(); + if (!id) return null; + return this.blameRequestDbService.findById(id); + } + + private async assertExpertActorOnClaim(claim: any, actor: any): Promise { + const blame = await this.loadBlameForClaim(claim); + assertClaimCaseForExpertActor(claim, actor, blame); + } + + private async fieldExpertOwnsClaim( + claim: any, + actor: { sub: string }, + ): Promise { + const blame = await this.loadBlameForClaim(claim); + return claimCaseInitiatedByFieldExpert(claim, actor, blame); + } + /** Load immutable profile fields from `damage-expert` for the acting expert. */ private async snapshotDamageExpert(actorId: string) { const doc = await this.damageExpertDbService.findById(actorId); @@ -2151,7 +2173,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(claim, actor); const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); @@ -2402,11 +2424,11 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(claim, actor); const isFieldExpertOwner = (actor as any).role === RoleEnum.FIELD_EXPERT && - claimCaseInitiatedByFieldExpert(claim, actor); + (await this.fieldExpertOwnsClaim(claim, actor)); const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); const isDamageReviewLock = @@ -2745,7 +2767,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( @@ -2910,7 +2932,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( @@ -3579,8 +3601,23 @@ export class ExpertClaimService { actor: any, query: ListQueryV2Dto = {}, ): Promise { + const expertOid = new Types.ObjectId(actor.sub); + const expertBlameIds = await this.blameRequestDbService + .find( + { expertInitiated: true, initiatedByFieldExpertId: expertOid }, + { select: "_id", lean: true }, + ) + .then((docs) => docs.map((d) => (d as { _id: unknown })._id)); + + const claimOr: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + ]; + if (expertBlameIds.length > 0) { + claimOr.push({ blameRequestId: { $in: expertBlameIds } }); + } + const claims = (await this.claimCaseDbService.find({ - initiatedByFieldExpertId: new Types.ObjectId(actor.sub), + $or: claimOr, })) as any[]; const blameIds = [ @@ -4008,7 +4045,8 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + const linkedBlame = await this.loadBlameForClaim(claim); + assertClaimCaseForExpertActor(claim, actor, linkedBlame); // Variables used both in the gate block and in the detail-build section below const isDamageExpertPhase = @@ -4019,7 +4057,7 @@ export class ExpertClaimService { // Field experts can always view their own initiated claims regardless of status if (actor.role === RoleEnum.FIELD_EXPERT) { - if (!claimCaseInitiatedByFieldExpert(claim, actor)) { + if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { throw new ForbiddenException("This claim was not initiated by you."); } // Fall through to the detail build below (skip the damage-expert gate) @@ -4317,7 +4355,7 @@ export class ExpertClaimService { /** V2 price-drop: claim locked by this expert during damage assessment. */ private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void { if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`, @@ -4551,7 +4589,7 @@ export class ExpertClaimService { if (!claim) { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForExpertActor(claim, actor); + await this.assertExpertActorOnClaim(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 f774c31..9bb33dd 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -115,7 +115,8 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "List available claim requests for damage expert", description: - "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", + "Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " + + "Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.", }) @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto }) async getClaimListV2( diff --git a/src/helpers/party-access-queries.spec.ts b/src/helpers/party-access-queries.spec.ts new file mode 100644 index 0000000..1b2faad --- /dev/null +++ b/src/helpers/party-access-queries.spec.ts @@ -0,0 +1,61 @@ +import { Types } from "mongoose"; +import { partyPersonMatchesUser } from "./iran-mobile"; +import { + buildBlamePartyAccessOrConditions, + mongoUserIdEqualityFilter, +} from "./party-access-queries"; + +describe("party access queries", () => { + const userId = new Types.ObjectId(); + + describe("mongoUserIdEqualityFilter", () => { + it("includes both ObjectId and string forms", () => { + const filter = mongoUserIdEqualityFilter( + "parties.person.userId", + String(userId), + ); + expect(filter).toEqual({ + $or: [ + { "parties.person.userId": String(userId) }, + { "parties.person.userId": userId }, + ], + }); + }); + }); + + describe("buildBlamePartyAccessOrConditions", () => { + it("builds userId and phone branches", () => { + const or = buildBlamePartyAccessOrConditions({ + sub: String(userId), + username: "09123456789", + }); + expect(or).toHaveLength(2); + expect(or[0]).toHaveProperty("$or"); + expect(or[1]).toEqual({ + "parties.person.phoneNumber": { + $in: expect.arrayContaining(["09123456789", "9123456789"]), + }, + }); + }); + }); + + describe("partyPersonMatchesUser", () => { + it("matches party userId stored as ObjectId against string JWT sub", () => { + expect( + partyPersonMatchesUser( + { userId, phoneNumber: "09111111111" }, + { sub: String(userId), username: "09999999999" }, + ), + ).toBe(true); + }); + + it("matches phone variants when userId is not set yet", () => { + expect( + partyPersonMatchesUser( + { phoneNumber: "9123456789" }, + { sub: "other", username: "09123456789" }, + ), + ).toBe(true); + }); + }); +}); diff --git a/src/helpers/party-access-queries.ts b/src/helpers/party-access-queries.ts new file mode 100644 index 0000000..17d64d4 --- /dev/null +++ b/src/helpers/party-access-queries.ts @@ -0,0 +1,87 @@ +import { Types } from "mongoose"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { iranMobileLookupVariants } from "./iran-mobile"; + +/** + * Mongo filter matching a user id field stored as ObjectId or legacy string. + */ +export function mongoUserIdEqualityFilter( + fieldPath: string, + userSub: string, +): Record | null { + if (!userSub) return null; + const variants: unknown[] = [userSub]; + if (Types.ObjectId.isValid(userSub)) { + variants.push(new Types.ObjectId(userSub)); + } + if (variants.length === 1) { + return { [fieldPath]: variants[0] }; + } + return { $or: variants.map((v) => ({ [fieldPath]: v })) }; +} + +/** `$or` branches: user is a party on a blame case (by userId and/or phone). */ +export function buildBlamePartyAccessOrConditions(user: { + sub?: string; + username?: string; +}): Record[] { + const or: Record[] = []; + const userIdCond = user?.sub + ? mongoUserIdEqualityFilter("parties.person.userId", user.sub) + : null; + if (userIdCond) or.push(userIdCond); + + const phoneVariants = user?.username + ? iranMobileLookupVariants(user.username) + : []; + if (phoneVariants.length > 0) { + or.push({ "parties.person.phoneNumber": { $in: phoneVariants } }); + } + return or; +} + +/** `$or` branches: user owns a claim or is tied via blame party membership. */ +export function buildClaimOwnerAccessOrConditions( + userSub: string, + username?: string, +): Record[] { + const or: Record[] = []; + const ownerIdCond = mongoUserIdEqualityFilter("owner.userId", userSub); + if (ownerIdCond) or.push(ownerIdCond); + return or; +} + +export function buildFieldExpertInitiatedBlameFilter( + expertSub: string, +): Record { + const expertOid = Types.ObjectId.isValid(expertSub) + ? new Types.ObjectId(expertSub) + : expertSub; + return { + expertInitiated: true, + initiatedByFieldExpertId: expertOid, + }; +} + +export function buildFieldExpertClaimAccessOrConditions( + expertSub: string, + expertBlameIds: unknown[], +): Record[] { + const expertOid = Types.ObjectId.isValid(expertSub) + ? new Types.ObjectId(expertSub) + : expertSub; + const or: Record[] = [ + { initiatedByFieldExpertId: expertOid }, + ]; + if (expertBlameIds.length > 0) { + or.push({ blameRequestId: { $in: expertBlameIds } }); + } + return or; +} + +/** True when actor is the field expert who initiated this blame (any JWT shape). */ +export function actorIsFieldExpertInitiator( + actor: { sub?: string; role?: string } | null | undefined, +): boolean { + return actor?.role === RoleEnum.FIELD_EXPERT && !!actor?.sub; +} diff --git a/src/helpers/tenant-scope.spec.ts b/src/helpers/tenant-scope.spec.ts new file mode 100644 index 0000000..f89e02c --- /dev/null +++ b/src/helpers/tenant-scope.spec.ts @@ -0,0 +1,86 @@ +import { Types } from "mongoose"; +import { + blameCaseInitiatedByFieldExpert, + claimCaseInitiatedByFieldExpert, +} from "./tenant-scope"; + +describe("field expert ownership helpers", () => { + const expertId = new Types.ObjectId(); + const otherExpertId = new Types.ObjectId(); + const blameId = new Types.ObjectId(); + + describe("blameCaseInitiatedByFieldExpert", () => { + it("matches when initiatedByFieldExpertId equals actor.sub (string)", () => { + expect( + blameCaseInitiatedByFieldExpert( + { + expertInitiated: true, + initiatedByFieldExpertId: expertId, + }, + { sub: String(expertId) }, + ), + ).toBe(true); + }); + + it("does not require actor.role on JWT", () => { + expect( + blameCaseInitiatedByFieldExpert( + { + expertInitiated: true, + initiatedByFieldExpertId: expertId, + }, + { sub: String(expertId) }, + ), + ).toBe(true); + }); + + it("rejects another expert", () => { + expect( + blameCaseInitiatedByFieldExpert( + { + expertInitiated: true, + initiatedByFieldExpertId: expertId, + }, + { sub: String(otherExpertId) }, + ), + ).toBe(false); + }); + }); + + describe("claimCaseInitiatedByFieldExpert", () => { + it("matches claim.initiatedByFieldExpertId", () => { + expect( + claimCaseInitiatedByFieldExpert( + { initiatedByFieldExpertId: expertId }, + { sub: String(expertId) }, + ), + ).toBe(true); + }); + + it("matches via linked expert-initiated blame when claim field is missing", () => { + expect( + claimCaseInitiatedByFieldExpert( + { blameRequestId: blameId }, + { sub: String(expertId) }, + { + expertInitiated: true, + initiatedByFieldExpertId: expertId, + }, + ), + ).toBe(true); + }); + + it("rejects when neither claim nor blame links to actor", () => { + expect( + claimCaseInitiatedByFieldExpert( + { blameRequestId: blameId }, + { sub: String(expertId) }, + { + expertInitiated: true, + initiatedByFieldExpertId: otherExpertId, + }, + ), + ).toBe(false); + }); + }); +}); diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts index 2c2ae8d..13dab4a 100644 --- a/src/helpers/tenant-scope.ts +++ b/src/helpers/tenant-scope.ts @@ -1,5 +1,4 @@ import { BadRequestException, ForbiddenException } from "@nestjs/common"; -import { RoleEnum } from "src/Types&Enums/role.enum"; /** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */ export function requireActorClientKey(actor: { clientKey?: string }): string { @@ -65,13 +64,12 @@ export function blameCaseAccessibleToExpert( return blameCaseTouchesClient(doc, ck); } -/** True when the acting field expert created this expert-initiated blame file. */ +/** True when `actor` is the field expert who created this expert-initiated blame file. */ export function blameCaseInitiatedByFieldExpert( doc: any, - actor: { sub: string; role?: string }, + actor: { sub: string }, ): boolean { return ( - actor.role === RoleEnum.FIELD_EXPERT && !!doc?.expertInitiated && !!doc?.initiatedByFieldExpertId && String(doc.initiatedByFieldExpertId) === String(actor.sub) @@ -102,28 +100,40 @@ export function assertClaimCaseForTenant( } /** - * Returns true when a field expert (no clientKey) initiated this claim via an - * expert-initiated IN_PERSON blame file. + * True when the field expert initiated this claim directly or via the linked + * expert-initiated blame file (`blameRequestId` → blame.initiatedByFieldExpertId). */ export function claimCaseInitiatedByFieldExpert( doc: any, actor: { sub: string }, + blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null, ): boolean { - return ( - !!doc.initiatedByFieldExpertId && + if ( + doc?.initiatedByFieldExpertId && String(doc.initiatedByFieldExpertId) === String(actor.sub) - ); + ) { + return true; + } + if ( + blame?.expertInitiated && + blame?.initiatedByFieldExpertId && + String(blame.initiatedByFieldExpertId) === String(actor.sub) + ) { + return true; + } + return false; } /** * Authorization check that works for both damage experts (clientKey tenant - * scope) and field experts (initiatedByFieldExpertId ownership). + * scope) and field experts (initiatedByFieldExpertId / linked blame ownership). */ export function assertClaimCaseForExpertActor( doc: any, actor: { sub: string; clientKey?: string }, + blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null, ): void { - if (claimCaseInitiatedByFieldExpert(doc, actor)) return; + if (claimCaseInitiatedByFieldExpert(doc, actor, blame)) return; assertClaimCaseForTenant(doc, actor); } diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 9ca5a00..6a76763 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -97,6 +97,7 @@ import { normalizeIranMobile, partyPersonMatchesUser, } from "src/helpers/iran-mobile"; +import { buildBlamePartyAccessOrConditions } from "src/helpers/party-access-queries"; @Injectable() export class RequestManagementService { @@ -5028,24 +5029,7 @@ export class RequestManagementService { query: ListQueryV2Dto = {}, ): Promise { try { - const userIdFilter = - user?.sub && Types.ObjectId.isValid(user.sub) - ? { "parties.person.userId": new Types.ObjectId(user.sub) } - : null; - const phoneVariants = - user?.role === RoleEnum.USER && user?.username - ? iranMobileLookupVariants(user.username) - : user?.username - ? [user.username] - : []; - const phoneFilter = - phoneVariants.length > 0 - ? { "parties.person.phoneNumber": { $in: phoneVariants } } - : null; - - const orConditions: Record[] = []; - if (userIdFilter) orConditions.push(userIdFilter); - if (phoneFilter) orConditions.push(phoneFilter); + const orConditions = buildBlamePartyAccessOrConditions(user); if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) { orConditions.push({ From 92e05d2a499d70df7852a5b0261f082e35e48ee9 Mon Sep 17 00:00:00 2001 From: Sepehr Yahyaee Date: Thu, 18 Jun 2026 18:22:15 +0330 Subject: [PATCH 3/3] Fix async error --- src/expert-claim/expert-claim.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 68d9ea2..9a854f2 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -4353,7 +4353,10 @@ export class ExpertClaimService { } /** V2 price-drop: claim locked by this expert during damage assessment. */ - private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void { + private async assertExpertCanEditClaimDuringReviewV2( + claim: any, + actor: any, + ): Promise { if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); await this.assertExpertActorOnClaim(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { @@ -4379,7 +4382,7 @@ export class ExpertClaimService { if (!claim) { throw new NotFoundException("Claim request not found"); } - this.assertExpertCanEditClaimDuringReviewV2(claim, actor); + await this.assertExpertCanEditClaimDuringReviewV2(claim, actor); const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( @@ -4433,7 +4436,7 @@ export class ExpertClaimService { if (!claim) { throw new NotFoundException("Claim request not found"); } - this.assertExpertCanEditClaimDuringReviewV2(claim, actor); + await this.assertExpertCanEditClaimDuringReviewV2(claim, actor); const vehicleSet: Record = {}; if (body.carName != null && String(body.carName).trim()) {