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:
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
61
src/helpers/party-access-queries.spec.ts
Normal file
61
src/helpers/party-access-queries.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/helpers/party-access-queries.ts
Normal file
87
src/helpers/party-access-queries.ts
Normal 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;
|
||||||
|
}
|
||||||
86
src/helpers/tenant-scope.spec.ts
Normal file
86
src/helpers/tenant-scope.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user