Fixed Lock for Field expert + user view of files

This commit is contained in:
2026-06-18 13:31:56 +03:30
parent 084d0e1360
commit fa188862e5
8 changed files with 328 additions and 234 deletions

191
package-lock.json generated
View File

@@ -179,24 +179,6 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -204,22 +186,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -275,24 +241,6 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -300,22 +248,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@angular-devkit/schematics/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -1075,9 +1007,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@hapi/tlds": { "node_modules/@hapi/tlds": {
"version": "1.1.6", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz",
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2175,6 +2107,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2192,6 +2127,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2209,6 +2147,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2226,6 +2167,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2243,6 +2187,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2905,24 +2852,6 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -2930,22 +2859,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@nestjs/schematics/node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -3340,6 +3253,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3357,6 +3273,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3374,6 +3293,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3391,6 +3313,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT", "license": "Apache-2.0 AND MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5794,24 +5719,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/chrome-trace-event": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -7254,16 +7161,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -7711,9 +7618,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -8810,9 +8717,9 @@
} }
}, },
"node_modules/joi": { "node_modules/joi": {
"version": "18.2.1", "version": "18.2.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz",
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@hapi/address": "^5.1.1", "@hapi/address": "^5.1.1",
@@ -10396,22 +10303,6 @@
"node": ">= 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": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",

View File

@@ -59,6 +59,7 @@ import {
} from "./dto/my-claims-v2.dto"; } from "./dto/my-claims-v2.dto";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; 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"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
@@ -4786,6 +4787,26 @@ export class ClaimRequestManagementService {
return; 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 (actor?.role === RoleEnum.FIELD_EXPERT) {
if (claim.initiatedByFieldExpertId?.toString() === currentUserId) { if (claim.initiatedByFieldExpertId?.toString() === currentUserId) {
return; return;
@@ -7223,8 +7244,25 @@ export class ClaimRequestManagementService {
{ lean: true }, { lean: true },
); );
} else { } 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( 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 }, { lean: true },
); );
} }

View File

@@ -11,6 +11,7 @@ import {
import { import {
assertBlameCaseForExpertTenant, assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert, blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } from "src/helpers/tenant-scope";
import { import {
@@ -436,8 +437,9 @@ export class ExpertBlameService {
} }
/** /**
* Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files only. * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files.
* IN_PERSON flows are AGREED and handled outside this panel; completed blames appear in expert-claim. * 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( private async getFieldExpertBlameListV2(
actor: { sub: string }, actor: { sub: string },
@@ -448,9 +450,25 @@ export class ExpertBlameService {
const visibleCases = (await this.blameRequestDbService.find( const visibleCases = (await this.blameRequestDbService.find(
{ {
blameStatus: BlameStatus.DISAGREEMENT,
expertInitiated: true, expertInitiated: true,
initiatedByFieldExpertId: expertOid, 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 }, { lean: true },
)) as Record<string, unknown>[]; )) as Record<string, unknown>[];
@@ -966,6 +984,8 @@ export class ExpertBlameService {
); );
} }
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
const decision = (doc.expert as any)?.decision; const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId const decidedByExpertId = decision?.decidedByExpertId
? String(decision.decidedByExpertId) ? String(decision.decidedByExpertId)
@@ -978,6 +998,7 @@ export class ExpertBlameService {
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
); );
if (!isFieldExpertOwner) {
// Access gates — must satisfy at least one bucket // Access gates — must satisfy at least one bucket
const isAvailable = const isAvailable =
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId; doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
@@ -1002,6 +1023,7 @@ export class ExpertBlameService {
"You do not have permission to view this request.", "You do not have permission to view this request.",
); );
} }
}
// Build evidence URLs // Build evidence URLs
const parties = Array.isArray(doc.parties) ? doc.parties : []; const parties = Array.isArray(doc.parties) ? doc.parties : [];
@@ -1158,7 +1180,12 @@ export class ExpertBlameService {
*/ */
async assignBlameCaseForReviewV2( async assignBlameCaseForReviewV2(
requestId: string, requestId: string,
actor: { sub: string; fullName?: string; clientKey?: string }, actor: {
sub: string;
fullName?: string;
clientKey?: string;
role?: string;
},
): Promise<ExpertFileAssignResultDto> { ): Promise<ExpertFileAssignResultDto> {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
@@ -1169,6 +1196,8 @@ export class ExpertBlameService {
assertBlameCaseForExpertTenant(request, actor); assertBlameCaseForExpertTenant(request, actor);
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor);
if (request.type === BlameRequestType.CAR_BODY) { if (request.type === BlameRequestType.CAR_BODY) {
throw new BadRequestException({ throw new BadRequestException({
success: false, 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 ( if (
request.status !== CaseStatus.WAITING_FOR_EXPERT || !isFieldExpertOwner &&
request.blameStatus !== BlameStatus.DISAGREEMENT request.status !== CaseStatus.WAITING_FOR_EXPERT
) { ) {
throw new BadRequestException({ throw new BadRequestException({
success: false, 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 ( if (
request.expertInitiated && request.expertInitiated &&
request.initiatedByFieldExpertId && request.initiatedByFieldExpertId &&
@@ -1272,7 +1329,6 @@ export class ExpertBlameService {
const assignFilter: Record<string, unknown> = { const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId), _id: new Types.ObjectId(requestId),
status: CaseStatus.WAITING_FOR_EXPERT,
blameStatus: BlameStatus.DISAGREEMENT, blameStatus: BlameStatus.DISAGREEMENT,
$and: [ $and: [
{ {
@@ -1293,8 +1349,11 @@ export class ExpertBlameService {
], ],
}; };
if (request.expertInitiated) { if (isFieldExpertOwner) {
assignFilter.initiatedByFieldExpertId = expertOid; assignFilter.initiatedByFieldExpertId = expertOid;
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
} else {
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
} }
const updated = await this.blameRequestDbService.findOneAndUpdate( const updated = await this.blameRequestDbService.findOneAndUpdate(

View File

@@ -43,7 +43,7 @@ export class ExpertBlameV2Controller {
summary: "List blame cases for expert review (V2)", summary: "List blame cases for expert review (V2)",
description: description:
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + "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. " + "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`.", "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
}) })

View File

@@ -2437,12 +2437,35 @@ export class ExpertClaimService {
assertClaimCaseForExpertActor(claim, actor); assertClaimCaseForExpertActor(claim, actor);
const isFieldExpertOwner =
(actor as any).role === RoleEnum.FIELD_EXPERT &&
claimCaseInitiatedByFieldExpert(claim, actor);
const isFactorValidationLock = const isFactorValidationLock =
claimIsAwaitingExpertFactorValidationV2(claim); claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock = const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; 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({ throw new BadRequestException({
success: false, success: false,
status: "unavailable" satisfies ExpertFileAssignStatus, status: "unavailable" satisfies ExpertFileAssignStatus,
@@ -2490,11 +2513,15 @@ export class ExpertClaimService {
const now = new Date(); const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); 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 = { const lockedByPayload = {
actorId: expertOid, actorId: expertOid,
actorName: actor.fullName, actorName: actor.fullName,
actorRole: "damage_expert" as const, actorRole: actorRoleLabel,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}; };
@@ -2503,34 +2530,40 @@ export class ExpertClaimService {
"workflow.lockedAt": now, "workflow.lockedAt": now,
"workflow.expiredAt": expiredAt, "workflow.expiredAt": expiredAt,
"workflow.lockedBy": lockedByPayload, "workflow.lockedBy": lockedByPayload,
"workflow.preLockQueueSnapshot": { ...(isDamageReviewLock || isExpertReviewingReentry || isResendPending
claimStatus: claim.claimStatus, ? {
currentStep: this.normalizeClaimWorkflowStepForSnapshot( "workflow.preLockQueueSnapshot": {
claim.workflow?.currentStep, claimStatus: claim.claimStatus,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, currentStep: this.normalizeClaimWorkflowStepForSnapshot(
), claim.workflow?.currentStep,
...(claim.workflow?.nextStep != null ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
), ),
} ...(claim.workflow?.nextStep != null
: {}), ? {
}, nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}),
},
}
: {}),
$push: { $push: {
history: { history: {
type: "CLAIM_ASSIGNED", type: "CLAIM_ASSIGNED",
actor: { actor: {
actorId: expertOid, actorId: expertOid,
actorName: actor.fullName, actorName: actor.fullName,
actorType: "damage_expert", actorType,
}, },
timestamp: now, timestamp: now,
metadata: { metadata: {
note: isFactorValidationLock note: isFactorValidationLock
? "Expert assigned for factor validation review" ? "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" } : {}), ...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
}, },
}, },
@@ -2541,14 +2574,18 @@ export class ExpertClaimService {
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
} }
const lockUpdate: Record<string, unknown> = isFactorValidationLock const lockUpdate: Record<string, unknown> =
? baseLockUpdate isFactorValidationLock ||
: { isExpertReviewingReentry ||
...baseLockUpdate, isResendPending ||
status: ClaimCaseStatus.EXPERT_REVIEWING, isFieldExpertPortfolioLock
claimStatus: ClaimStatus.UNDER_REVIEW, ? baseLockUpdate
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, : {
}; ...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
const assignFilter: Record<string, unknown> = { const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId), _id: new Types.ObjectId(claimRequestId),
@@ -2581,6 +2618,13 @@ export class ExpertClaimService {
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, 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 { } else {
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
} }
@@ -2634,7 +2678,13 @@ export class ExpertClaimService {
}); });
const ownerPhone = await this.resolveClaimOwnerPhone(updated); const ownerPhone = await this.resolveClaimOwnerPhone(updated);
if (ownerPhone) { if (
ownerPhone &&
isDamageReviewLock &&
!isFieldExpertPortfolioLock &&
!isExpertReviewingReentry &&
!isResendPending
) {
const expertLastName = const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(

View File

@@ -44,3 +44,28 @@ export function buildUserLookupByPhone(
$or: variants.flatMap((v) => [{ username: v }, { mobile: v }]), $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,
);
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common"; 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). */ /** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
export function requireActorClientKey(actor: { clientKey?: string }): string { export function requireActorClientKey(actor: { clientKey?: string }): string {
@@ -64,6 +65,19 @@ export function blameCaseAccessibleToExpert(
return blameCaseTouchesClient(doc, ck); 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( export function assertBlameCaseForExpertTenant(
doc: any, doc: any,
actor: { sub: string; clientKey?: string }, actor: { sub: string; clientKey?: string },

View File

@@ -91,6 +91,12 @@ import {
MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
} from "src/helpers/blame-party-agreement-decision"; } from "src/helpers/blame-party-agreement-decision";
import { buildFileLink } from "src/helpers/urlCreator"; import { buildFileLink } from "src/helpers/urlCreator";
import {
buildUserLookupByPhone,
iranMobileLookupVariants,
normalizeIranMobile,
partyPersonMatchesUser,
} from "src/helpers/iran-mobile";
@Injectable() @Injectable()
export class RequestManagementService { export class RequestManagementService {
@@ -209,14 +215,8 @@ export class RequestManagementService {
private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) { private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) {
// The initiating field-expert/registrar fills steps on behalf of parties. // The initiating field-expert/registrar fills steps on behalf of parties.
if (req && this.isBlameOnBehalfActor(req, user)) return; if (req && this.isBlameOnBehalfActor(req, user)) return;
const partyUserId = party?.person?.userId if (partyPersonMatchesUser(party?.person, user)) return;
? String(party.person.userId) throw new ForbiddenException(errMsg);
: null;
const ok =
(partyUserId && partyUserId === String(user?.sub)) ||
(party?.person?.phoneNumber &&
party.person.phoneNumber === user?.username);
if (!ok) throw new ForbiddenException(errMsg);
} }
/** /**
@@ -4434,7 +4434,8 @@ export class RequestManagementService {
throw new BadRequestException("First party not found on request"); throw new BadRequestException("First party not found on request");
const userId = await this.getOrCreateUserByPhoneNumber(phone); const userId = await this.getOrCreateUserByPhoneNumber(phone);
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; 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; req.parties[firstIdx].person.userId = userId;
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
@@ -4591,7 +4592,9 @@ export class RequestManagementService {
throw new BadRequestException("First party not found on request"); throw new BadRequestException("First party not found on request");
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = firstUserId; req.parties[firstIdx].person.userId = firstUserId;
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber; req.parties[firstIdx].person.phoneNumber =
normalizeIranMobile(dto.firstPartyPhoneNumber) ??
dto.firstPartyPhoneNumber;
if ( if (
req.type === BlameRequestType.THIRD_PARTY && req.type === BlameRequestType.THIRD_PARTY &&
@@ -4617,7 +4620,9 @@ export class RequestManagementService {
if (!req.parties[secondIdx].person) if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = secondUserId; 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 ( } else if (
req.type === BlameRequestType.THIRD_PARTY && req.type === BlameRequestType.THIRD_PARTY &&
@@ -4794,6 +4799,7 @@ export class RequestManagementService {
const valid = await this.hashService.compare(otp, u.otp); const valid = await this.hashService.compare(otp, u.otp);
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
const userId = u._id as Types.ObjectId; const userId = u._id as Types.ObjectId;
const canonicalPhone = normalizeIranMobile(phone) ?? phone;
if (role === PartyRole.FIRST) { if (role === PartyRole.FIRST) {
if (firstIdx === -1) if (firstIdx === -1)
@@ -4801,13 +4807,21 @@ export class RequestManagementService {
if (!req.parties[firstIdx].person) if (!req.parties[firstIdx].person)
req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = userId; req.parties[firstIdx].person.userId = userId;
req.parties[firstIdx].person.phoneNumber = phone; req.parties[firstIdx].person.phoneNumber = canonicalPhone;
} else { } else {
const firstPhone = const firstPhone =
firstIdx !== -1 firstIdx !== -1
? (req.parties[firstIdx]?.person as any)?.phoneNumber ? (req.parties[firstIdx]?.person as any)?.phoneNumber
: undefined; : undefined;
if (firstPhone && firstPhone === phone) { const firstVariants = firstPhone
? iranMobileLookupVariants(firstPhone)
: [];
if (
firstPhone &&
(firstPhone === phone ||
firstVariants.includes(phone) ||
firstVariants.includes(canonicalPhone))
) {
throw new BadRequestException( throw new BadRequestException(
"Second party phone number cannot be the same as first party.", "Second party phone number cannot be the same as first party.",
); );
@@ -4816,13 +4830,13 @@ export class RequestManagementService {
if (secondIdx === -1) { if (secondIdx === -1) {
req.parties.push({ req.parties.push({
role: PartyRole.SECOND, role: PartyRole.SECOND,
person: { userId, phoneNumber: phone }, person: { userId, phoneNumber: canonicalPhone },
} as any); } as any);
} else { } else {
if (!req.parties[secondIdx].person) if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = userId; 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) user?.sub && Types.ObjectId.isValid(user.sub)
? { "parties.person.userId": new Types.ObjectId(user.sub) } ? { "parties.person.userId": new Types.ObjectId(user.sub) }
: null; : null;
const phoneFilter = user?.username const phoneVariants =
? { "parties.person.phoneNumber": user.username } user?.role === RoleEnum.USER && user?.username
: null; ? iranMobileLookupVariants(user.username)
: user?.username
? [user.username]
: [];
const phoneFilter =
phoneVariants.length > 0
? { "parties.person.phoneNumber": { $in: phoneVariants } }
: null;
const orConditions: Record<string, unknown>[] = []; const orConditions: Record<string, unknown>[] = [];
if (userIdFilter) orConditions.push(userIdFilter); if (userIdFilter) orConditions.push(userIdFilter);
@@ -5061,11 +5082,8 @@ export class RequestManagementService {
req.registrarInitiated && req.registrarInitiated &&
String(req.initiatedByRegistrarId) === String(user.sub)); String(req.initiatedByRegistrarId) === String(user.sub));
const party = req.parties?.find( const party = req.parties?.find((p: any) =>
(p: any) => partyPersonMatchesUser(p?.person, user),
(p?.person?.userId &&
String(p.person.userId) === String(user.sub)) ||
(p?.person?.phoneNumber && p.person.phoneNumber === user?.username),
); );
const obj = req.toObject(); const obj = req.toObject();
@@ -5340,14 +5358,7 @@ export class RequestManagementService {
String(req.initiatedByRegistrarId) === String(user?.sub); String(req.initiatedByRegistrarId) === String(user?.sub);
const isParty = const isParty =
Array.isArray(req.parties) && Array.isArray(req.parties) &&
req.parties.some((p: any) => { req.parties.some((p: any) => partyPersonMatchesUser(p?.person, user));
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)
);
});
if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) { if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) {
throw new ForbiddenException("You do not have access to this request"); throw new ForbiddenException("You do not have access to this request");
} }
@@ -5359,10 +5370,22 @@ export class RequestManagementService {
Types.ObjectId.isValid(user.sub) Types.ObjectId.isValid(user.sub)
) { ) {
let updated = false; let updated = false;
const phoneVariants = iranMobileLookupVariants(user?.username);
for (const p of req.parties || []) { 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 = p.person || {};
(p.person as any).userId = new Types.ObjectId(user.sub); (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; updated = true;
} }
} }
@@ -5384,14 +5407,9 @@ export class RequestManagementService {
const visibleParties = const visibleParties =
isFieldExpertOwner || isRegistrarOwner isFieldExpertOwner || isRegistrarOwner
? allParties ? allParties
: allParties.filter((p: any) => { : allParties.filter((p: any) =>
const pid = p?.person?.userId ? String(p.person.userId) : null; partyPersonMatchesUser(p?.person, user),
const phone = p?.person?.phoneNumber; );
return (
(pid && pid === String(user?.sub)) ||
(phone && phone === user?.username)
);
});
const parties = await Promise.all( const parties = await Promise.all(
visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)), visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)),
); );
@@ -7288,19 +7306,18 @@ export class RequestManagementService {
private async getOrCreateUserByPhoneNumber( private async getOrCreateUserByPhoneNumber(
phoneNumber: string, phoneNumber: string,
): Promise<Types.ObjectId> { ): Promise<Types.ObjectId> {
// Try to find existing user by phone number (username or mobile) const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim();
let user = await this.userDbService.findOne({ let user = await this.userDbService.findOne(
$or: [{ username: phoneNumber }, { mobile: phoneNumber }], buildUserLookupByPhone(canonical),
}); );
if (user) { if (user) {
return new Types.ObjectId((user as any)._id); return new Types.ObjectId((user as any)._id);
} }
// User doesn't exist, create a new one
const newUser = await this.userDbService.createUser({ const newUser = await this.userDbService.createUser({
mobile: phoneNumber, mobile: canonical,
username: phoneNumber, username: canonical,
otp: "", otp: "",
tokens: { tokens: {
token: "", token: "",