1
0
forked from Yara724/api

Merge pull request 'Fixed GET users' (#131) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#131
This commit is contained in:
2026-06-18 18:28:57 +03:30
11 changed files with 374 additions and 105 deletions

View File

@@ -84,6 +84,9 @@
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],

View File

@@ -60,6 +60,11 @@ import {
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 { 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 { 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";
@@ -4808,21 +4813,16 @@ export class ClaimRequestManagementService {
} }
if (actor?.role === RoleEnum.FIELD_EXPERT) { 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; 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) { if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
@@ -7200,25 +7200,24 @@ export class ClaimRequestManagementService {
try { try {
let claims: any[]; let claims: any[];
if (actor?.role === RoleEnum.FIELD_EXPERT) { if (actor?.role === RoleEnum.FIELD_EXPERT) {
const expertOid = new Types.ObjectId(currentUserId);
const expertBlameIds = await this.blameRequestDbService const expertBlameIds = await this.blameRequestDbService
.find( .find(
{ {
expertInitiated: true, expertInitiated: true,
initiatedByFieldExpertId: new Types.ObjectId(currentUserId), initiatedByFieldExpertId: expertOid,
}, },
{ select: "_id", lean: true }, { select: "_id", lean: true },
) )
.then((docs) => docs.map((d) => (d as any)._id)); .then((docs) => docs.map((d) => (d as any)._id));
const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
}
claims = await this.claimCaseDbService.find( claims = await this.claimCaseDbService.find(
{ { $or: claimOr },
$or: [
{ "owner.userId": new Types.ObjectId(currentUserId) },
...(expertBlameIds.length
? [{ blameRequestId: { $in: expertBlameIds } }]
: []),
{ initiatedByFieldExpertId: new Types.ObjectId(currentUserId) },
],
},
{ lean: true }, { lean: true },
); );
} else if (actor?.role === RoleEnum.REGISTRAR) { } else if (actor?.role === RoleEnum.REGISTRAR) {
@@ -7244,27 +7243,38 @@ export class ClaimRequestManagementService {
{ lean: true }, { lean: true },
); );
} else { } else {
const partyBlameOr = buildBlamePartyAccessOrConditions({
sub: currentUserId,
username: (actor as { username?: string })?.username,
});
const partyBlameIds = await this.blameRequestDbService const partyBlameIds = await this.blameRequestDbService
.find( .find(
{ partyBlameOr.length === 1
$or: [ ? (partyBlameOr[0] as any)
{ "parties.person.userId": new Types.ObjectId(currentUserId) }, : ({ $or: partyBlameOr } as any),
],
},
{ select: "_id", lean: true }, { select: "_id", lean: true },
) )
.then((docs) => docs.map((d) => (d as any)._id)); .then((docs) => docs.map((d) => (d as any)._id));
claims = await this.claimCaseDbService.find(
{ const ownerOr = mongoUserIdEqualityFilter(
$or: [ "owner.userId",
{ "owner.userId": new Types.ObjectId(currentUserId) }, currentUserId,
...(partyBlameIds.length
? [{ blameRequestId: { $in: partyBlameIds } }]
: []),
],
},
{ lean: true },
); );
const claimOr: Record<string, unknown>[] = [];
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) => ({ const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(), claimRequestId: c._id.toString(),

View File

@@ -437,9 +437,9 @@ export class ExpertBlameService {
} }
/** /**
* Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files. * Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created
* Covers LINK flows (parties complete via link → WAITING_FOR_EXPERT) and in-progress * (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually
* disputes while parties are still filling. IN_PERSON AGREED blames use expert-claim instead. * via expert-claim after blame completion.
*/ */
private async getFieldExpertBlameListV2( private async getFieldExpertBlameListV2(
actor: { sub: string }, actor: { sub: string },
@@ -452,23 +452,6 @@ export class ExpertBlameService {
{ {
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>[];
@@ -967,7 +950,12 @@ export class ExpertBlameService {
// Tenant check // Tenant check
assertBlameCaseForExpertTenant(doc, actor); assertBlameCaseForExpertTenant(doc, actor);
if (doc.type === BlameRequestType.CAR_BODY) { const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
if (
doc.type === BlameRequestType.CAR_BODY &&
!isFieldExpertOwner
) {
throw new ForbiddenException( throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.", "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 decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId const decidedByExpertId = decision?.decidedByExpertId
? String(decision.decidedByExpertId) ? String(decision.decidedByExpertId)

View File

@@ -43,8 +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`): expert-initiated **DISAGREEMENT** files — including **LINK** flows where parties complete via link and the case moves to `WAITING_FOR_EXPERT` for your review. " + "Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " +
"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`.",
}) })
async findAll( async findAll(

View File

@@ -355,6 +355,28 @@ export class ExpertClaimService {
return ev; 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<void> {
const blame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, blame);
}
private async fieldExpertOwnsClaim(
claim: any,
actor: { sub: string },
): Promise<boolean> {
const blame = await this.loadBlameForClaim(claim);
return claimCaseInitiatedByFieldExpert(claim, actor, blame);
}
/** Load immutable profile fields from `damage-expert` for the acting expert. */ /** Load immutable profile fields from `damage-expert` for the acting expert. */
private async snapshotDamageExpert(actorId: string) { private async snapshotDamageExpert(actorId: string) {
const doc = await this.damageExpertDbService.findById(actorId); const doc = await this.damageExpertDbService.findById(actorId);
@@ -2167,7 +2189,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
@@ -2435,11 +2457,11 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
const isFieldExpertOwner = const isFieldExpertOwner =
(actor as any).role === RoleEnum.FIELD_EXPERT && (actor as any).role === RoleEnum.FIELD_EXPERT &&
claimCaseInitiatedByFieldExpert(claim, actor); (await this.fieldExpertOwnsClaim(claim, actor));
const isFactorValidationLock = const isFactorValidationLock =
claimIsAwaitingExpertFactorValidationV2(claim); claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock = const isDamageReviewLock =
@@ -2778,7 +2800,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException( throw new BadRequestException(
@@ -2943,7 +2965,7 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException( throw new BadRequestException(
@@ -3614,8 +3636,23 @@ export class ExpertClaimService {
actor: any, actor: any,
query: ListQueryV2Dto = {}, query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> { ): Promise<GetClaimListV2ResponseDto> {
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<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
];
if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
}
const claims = (await this.claimCaseDbService.find({ const claims = (await this.claimCaseDbService.find({
initiatedByFieldExpertId: new Types.ObjectId(actor.sub), $or: claimOr,
})) as any[]; })) as any[];
const blameIds = [ const blameIds = [
@@ -4043,7 +4080,8 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); 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 // Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase = const isDamageExpertPhase =
@@ -4054,7 +4092,7 @@ export class ExpertClaimService {
// Field experts can always view their own initiated claims regardless of status // Field experts can always view their own initiated claims regardless of status
if (actor.role === RoleEnum.FIELD_EXPERT) { 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."); throw new ForbiddenException("This claim was not initiated by you.");
} }
// Fall through to the detail build below (skip the damage-expert gate) // Fall through to the detail build below (skip the damage-expert gate)
@@ -4350,9 +4388,12 @@ export class ExpertClaimService {
} }
/** V2 price-drop: claim locked by this expert during damage assessment. */ /** 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<void> {
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException( throw new BadRequestException(
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`, `Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
@@ -4376,7 +4417,7 @@ export class ExpertClaimService {
if (!claim) { if (!claim) {
throw new NotFoundException("Claim request not found"); 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 carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(
@@ -4430,7 +4471,7 @@ export class ExpertClaimService {
if (!claim) { if (!claim) {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
this.assertExpertCanEditClaimDuringReviewV2(claim, actor); await this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
const vehicleSet: Record<string, string> = {}; const vehicleSet: Record<string, string> = {};
if (body.carName != null && String(body.carName).trim()) { if (body.carName != null && String(body.carName).trim()) {
@@ -4586,7 +4627,7 @@ export class ExpertClaimService {
if (!claim) { if (!claim) {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
assertClaimCaseForExpertActor(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException( throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,

View File

@@ -115,7 +115,8 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
description: 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 }) @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
async getClaimListV2( async getClaimListV2(

View File

@@ -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);
});
});
});

View File

@@ -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<string, unknown> | 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<string, unknown>[] {
const or: Record<string, unknown>[] = [];
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<string, unknown>[] {
const or: Record<string, unknown>[] = [];
const ownerIdCond = mongoUserIdEqualityFilter("owner.userId", userSub);
if (ownerIdCond) or.push(ownerIdCond);
return or;
}
export function buildFieldExpertInitiatedBlameFilter(
expertSub: string,
): Record<string, unknown> {
const expertOid = Types.ObjectId.isValid(expertSub)
? new Types.ObjectId(expertSub)
: expertSub;
return {
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
};
}
export function buildFieldExpertClaimAccessOrConditions(
expertSub: string,
expertBlameIds: unknown[],
): Record<string, unknown>[] {
const expertOid = Types.ObjectId.isValid(expertSub)
? new Types.ObjectId(expertSub)
: expertSub;
const or: Record<string, unknown>[] = [
{ 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;
}

View File

@@ -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);
});
});
});

View File

@@ -65,13 +65,12 @@ export function blameCaseAccessibleToExpert(
return blameCaseTouchesClient(doc, ck); 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( export function blameCaseInitiatedByFieldExpert(
doc: any, doc: any,
actor: { sub: string; role?: string }, actor: { sub: string },
): boolean { ): boolean {
return ( return (
actor.role === RoleEnum.FIELD_EXPERT &&
!!doc?.expertInitiated && !!doc?.expertInitiated &&
!!doc?.initiatedByFieldExpertId && !!doc?.initiatedByFieldExpertId &&
String(doc.initiatedByFieldExpertId) === String(actor.sub) String(doc.initiatedByFieldExpertId) === String(actor.sub)
@@ -102,28 +101,40 @@ export function assertClaimCaseForTenant(
} }
/** /**
* Returns true when a field expert (no clientKey) initiated this claim via an * True when the field expert initiated this claim directly or via the linked
* expert-initiated IN_PERSON blame file. * expert-initiated blame file (`blameRequestId` → blame.initiatedByFieldExpertId).
*/ */
export function claimCaseInitiatedByFieldExpert( export function claimCaseInitiatedByFieldExpert(
doc: any, doc: any,
actor: { sub: string }, actor: { sub: string },
blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null,
): boolean { ): boolean {
return ( if (
!!doc.initiatedByFieldExpertId && doc?.initiatedByFieldExpertId &&
String(doc.initiatedByFieldExpertId) === String(actor.sub) 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 * 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( export function assertClaimCaseForExpertActor(
doc: any, doc: any,
actor: { sub: string; clientKey?: string }, actor: { sub: string; clientKey?: string },
blame?: { expertInitiated?: boolean; initiatedByFieldExpertId?: unknown } | null,
): void { ): void {
if (claimCaseInitiatedByFieldExpert(doc, actor)) return; if (claimCaseInitiatedByFieldExpert(doc, actor, blame)) return;
assertClaimCaseForTenant(doc, actor); assertClaimCaseForTenant(doc, actor);
} }

View File

@@ -97,6 +97,7 @@ import {
normalizeIranMobile, normalizeIranMobile,
partyPersonMatchesUser, partyPersonMatchesUser,
} from "src/helpers/iran-mobile"; } from "src/helpers/iran-mobile";
import { buildBlamePartyAccessOrConditions } from "src/helpers/party-access-queries";
@Injectable() @Injectable()
export class RequestManagementService { export class RequestManagementService {
@@ -5028,24 +5029,7 @@ export class RequestManagementService {
query: ListQueryV2Dto = {}, query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> { ): Promise<GetUserBlameListV2ResponseDto> {
try { try {
const userIdFilter = const orConditions = buildBlamePartyAccessOrConditions(user);
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<string, unknown>[] = [];
if (userIdFilter) orConditions.push(userIdFilter);
if (phoneFilter) orConditions.push(phoneFilter);
if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) { if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) {
orConditions.push({ orConditions.push({