Fixed users not being able to view their files

This commit is contained in:
2026-06-19 09:50:42 +03:30
parent 0c5756d325
commit 3c863bb90c
12 changed files with 745 additions and 222 deletions

View File

@@ -62,8 +62,18 @@ import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
import {
buildBlamePartyAccessOrConditions,
mongoUserIdEqualityFilter,
collectUserIdVariants,
} from "src/helpers/party-access-queries";
import {
buildOwnerUserIdAccessFilter,
ownerUserIdMatchesActor,
resolveLinkedUserIdStrings,
} from "src/helpers/user-access-resolver";
import {
blameDamagedPartyMatchesUser,
resolveClaimOwnerParty,
resolveDamagedPartyUserId,
} from "src/helpers/blame-damaged-party";
import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
@@ -2719,14 +2729,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claim.owner?.userId ||
claim.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"You do not have access to this claim file",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claim,
effectiveUserId,
actor as { username?: string },
"You do not have access to this claim file",
);
const replyKey = claim.evaluation?.damageExpertReplyFinal
? "damageExpertReplyFinal"
@@ -4580,6 +4588,19 @@ export class ClaimRequestManagementService {
const claimNo = await this.generateUniqueClaimNumber();
// 8. Create claim case
const ownerParty = resolveClaimOwnerParty(blameRequest);
if (!ownerParty?.person?.userId) {
throw new BadRequestException(
"Could not resolve claim owner from blame parties",
);
}
const damagedUserId = (() => {
const oid = String(ownerParty.person.userId);
const dp = (blameRequest.parties || []).find(
(p: any) => p.person?.userId && String(p.person.userId) !== oid,
);
return dp?.person?.userId ? String(dp.person.userId) : null;
})();
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
@@ -4594,32 +4615,19 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
owner: {
userId: new Types.ObjectId(currentUserId),
userRole: currentUserParty.role as any,
fullName: currentUserParty.person?.fullName,
userClientKey: currentUserParty.person?.clientId
? String(currentUserParty.person.clientId)
: undefined,
// clientId must be the GUILTY party's clientId — their insurer pays the claim
...(() => {
if (isCarBody) {
// CAR_BODY has no guilty party — use the first party's own clientId
const firstParty = parties.find((p) => p.role === "FIRST");
const cid = firstParty?.person?.clientId;
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
}
const guiltyPartyId = String(
blameRequest?.expert?.decision?.guiltyPartyId ?? "",
);
const guiltyParty = parties.find(
(p) => String(p.person?.userId) === guiltyPartyId,
);
const cid = guiltyParty?.person?.clientId;
return cid ? { clientId: new Types.ObjectId(String(cid)) } : {};
})(),
},
...(damagedUserId
? { damagedPartyUserId: new Types.ObjectId(damagedUserId) }
: {}),
owner: (() => {
const cid = ownerParty.person.clientId;
return {
userId: new Types.ObjectId(String(ownerParty.person.userId)),
userRole: ownerParty.role as any,
fullName: currentUserParty.person?.fullName,
userClientKey: cid ? String(cid) : undefined,
...(cid ? { clientId: new Types.ObjectId(String(cid)) } : {}),
};
})(),
history: [
{
type: "CLAIM_CREATED",
@@ -4725,9 +4733,12 @@ export class ClaimRequestManagementService {
throw new ConflictException("A claim for this blame case already exists");
}
const claimNo = await this.generateUniqueClaimNumber();
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
);
const ownerParty = resolveClaimOwnerParty(blameRequest);
if (!ownerParty?.person?.userId) {
throw new BadRequestException(
"Could not resolve claim owner (guilty party) from blame",
);
}
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
@@ -4743,12 +4754,15 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
damagedPartyUserId: new Types.ObjectId(damagedUserId),
owner: {
userId: new Types.ObjectId(damagedUserId),
userRole: damagedParty?.role as any,
...(damagedParty?.person?.clientId
userId: new Types.ObjectId(String(ownerParty.person.userId)),
userRole: ownerParty.role as any,
...(ownerParty.person.clientId
? {
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
clientId: new Types.ObjectId(
String(ownerParty.person.clientId),
),
}
: {}),
},
@@ -4782,32 +4796,128 @@ export class ClaimRequestManagementService {
};
}
private async assertActorCanViewClaimV2(
/**
* True when the actor appears as a non-owner (damaged) party on the claim's
* snapshot.parties. This is the fastest, blame-free fallback: the snapshot is
* written at claim-creation time and always holds the correct user IDs.
*
* Logic:
* - Any party whose person.userId is in linkedUserIds and is NOT the claim
* owner (guilty party) is considered the damaged party.
* - If owner.userId is unknown we accept any matching party (CAR_BODY flows).
*/
private claimSnapshotPartyMatchesAsDamagedParty(
claim: any,
currentUserId: string,
actor?: { sub: string; role?: string },
linkedUserIdStrings: string[],
): boolean {
const parties: any[] = claim?.snapshot?.parties ?? [];
if (!parties.length) return false;
const ownerIdStr = claim?.owner?.userId
? String(claim.owner.userId)
: null;
for (const party of parties) {
const partyUserId = party?.person?.userId;
if (partyUserId == null) continue;
const partyStr = String(partyUserId);
// Must be this actor
if (!linkedUserIdStrings.some((id) => id === partyStr)) continue;
// Must NOT be the guilty-owner (unless owner is unknown — CAR_BODY)
if (ownerIdStr && partyStr === ownerIdStr) continue;
return true;
}
return false;
}
private async assertEffectiveUserIsDamagedPartyOnClaim(
claimCase: any,
effectiveUserId: string,
actor?: { username?: string },
message = "Only the damaged party can perform this action on the claim",
): Promise<void> {
const ownerId = claim.owner?.userId?.toString();
if (ownerId && ownerId === currentUserId) {
const linkedUserIds = await resolveLinkedUserIdStrings(
this.userDbService,
{ sub: effectiveUserId, username: actor?.username },
);
const actorCtx = { sub: effectiveUserId, username: actor?.username };
// 0. damagedPartyUserId stored directly on the claim (fastest, most reliable)
if (ownerUserIdMatchesActor(claimCase?.damagedPartyUserId, linkedUserIds)) {
return;
}
if (
(!actor?.role || actor.role === RoleEnum.USER) &&
claim.blameRequestId
) {
// 1. Owner (= guilty party for THIRD_PARTY) — kept for backward compat
// on CAR_BODY where owner IS the damaged party.
if (ownerUserIdMatchesActor(claimCase?.owner?.userId, linkedUserIds)) {
return;
}
// 2. Blame-based resolution (primary path for THIRD_PARTY)
if (claimCase?.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(),
claimCase.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 (
blame &&
blameDamagedPartyMatchesUser(blame, actorCtx, linkedUserIds)
) {
return;
}
}
// 3. Snapshot-party fallback: claim.snapshot.parties is written at creation
// and is reliable even when blame lookup fails or userId mapping differs.
if (this.claimSnapshotPartyMatchesAsDamagedParty(claimCase, linkedUserIds)) {
return;
}
throw new ForbiddenException(message);
}
private async assertActorCanViewClaimV2(
claim: any,
currentUserId: string,
actor?: { sub: string; role?: string; username?: string },
): Promise<void> {
const linkedUserIds = await resolveLinkedUserIdStrings(
this.userDbService,
{ sub: currentUserId, username: actor?.username },
);
if (ownerUserIdMatchesActor(claim.owner?.userId, linkedUserIds)) {
return;
}
// damagedPartyUserId is set at claim-creation time — the most direct check.
if (ownerUserIdMatchesActor(claim.damagedPartyUserId, linkedUserIds)) {
return;
}
if (!actor?.role || actor.role === RoleEnum.USER) {
const userCtx = { sub: currentUserId, username: actor?.username };
// Any party on the linked blame can VIEW the claim.
// (Mutation guards use assertEffectiveUserIsDamagedPartyOnClaim which is
// stricter and only allows the damaged party to act.)
if (claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(),
);
if (isParty) {
if (blame) {
const parties: any[] = (blame as any).parties ?? [];
const isParty = parties.some((p: any) =>
partyPersonMatchesUser(p?.person, userCtx, linkedUserIds),
);
if (isParty) return;
}
}
// Snapshot-party fallback (reliable for claims with snapshot written).
if (this.claimSnapshotPartyMatchesAsDamagedParty(claim, linkedUserIds)) {
return;
}
}
@@ -4878,9 +4988,10 @@ export class ClaimRequestManagementService {
return currentUserId;
}
}
return claimCase.owner?.userId
? String(claimCase.owner.userId)
: currentUserId;
return (
resolveDamagedPartyUserId(blameRequest) ??
(claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId)
);
}
async createClaimFromBlameForRegistrarV1(
@@ -4936,9 +5047,12 @@ export class ClaimRequestManagementService {
if (existingClaim)
throw new ConflictException("A claim for this blame case already exists");
const claimNo = await this.generateUniqueClaimNumber();
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
);
const ownerParty = resolveClaimOwnerParty(blameRequest);
if (!ownerParty?.person?.userId) {
throw new BadRequestException(
"Could not resolve claim owner (guilty party) from blame",
);
}
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
@@ -4953,12 +5067,15 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
damagedPartyUserId: new Types.ObjectId(damagedUserId),
owner: {
userId: new Types.ObjectId(damagedUserId),
userRole: damagedParty?.role as any,
...(damagedParty?.person?.clientId
userId: new Types.ObjectId(String(ownerParty.person.userId)),
userRole: ownerParty.role as any,
...(ownerParty.person.clientId
? {
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
clientId: new Types.ObjectId(
String(ownerParty.person.clientId),
),
}
: {}),
},
@@ -5025,15 +5142,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
// 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim)
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner (damaged party) can select damaged parts",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner (damaged party) can select damaged parts",
);
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
if (
@@ -5245,15 +5359,13 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
// 2. Validate user is the claim owner (or field expert for IN_PERSON claim)
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner (damaged party) can submit other parts and bank information",
);
}
// 2. Validate user is the damaged party (or field expert for IN_PERSON claim)
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner (damaged party) can submit other parts and bank information",
);
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
if (
@@ -5468,14 +5580,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can view capture requirements",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can view capture requirements",
);
// Build car-type aware outer-parts lookup (complete source: static catalog).
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
@@ -5764,14 +5874,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can complete this step",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can complete this step",
);
if (
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
) {
@@ -5840,14 +5948,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can upload documents",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can upload documents",
);
const step = claimCase.workflow?.currentStep;
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
@@ -6186,12 +6292,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException("Only the claim owner can capture parts");
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can capture parts",
);
const capStep = claimCase.workflow?.currentStep;
const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
@@ -6483,14 +6589,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can upload car capture video",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can upload car capture video",
);
if (!fileDetail) {
throw new BadRequestException("Video file is required.");
@@ -6606,14 +6710,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can submit an objection",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can submit an objection",
);
if (claimCase.evaluation?.objection?.submittedAt) {
throw new ConflictException(
@@ -6827,14 +6929,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner can submit a satisfaction rating.",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner can submit a satisfaction rating.",
);
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
throw new BadRequestException(
@@ -6932,14 +7032,12 @@ export class ClaimRequestManagementService {
currentUserId,
actor?.role,
);
if (
!claimCase.owner?.userId ||
claimCase.owner.userId.toString() !== effectiveUserId
) {
throw new ForbiddenException(
"Only the claim owner (damaged party) can sign at this step.",
);
}
await this.assertEffectiveUserIsDamagedPartyOnClaim(
claimCase,
effectiveUserId,
actor as { username?: string },
"Only the claim owner (damaged party) can sign at this step.",
);
if (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) {
throw new BadRequestException(
@@ -7243,38 +7341,102 @@ export class ClaimRequestManagementService {
{ lean: true },
);
} else {
const partyBlameOr = buildBlamePartyAccessOrConditions({
const linkedUserIds = await resolveLinkedUserIdStrings(
this.userDbService,
{
sub: currentUserId,
username: (actor as { username?: string })?.username,
},
);
const actorCtx = {
sub: currentUserId,
username: (actor as { username?: string })?.username,
});
const partyBlameIds = await this.blameRequestDbService
.find(
partyBlameOr.length === 1
? (partyBlameOr[0] as any)
: ({ $or: partyBlameOr } as any),
{ select: "_id", lean: true },
)
.then((docs) => docs.map((d) => (d as any)._id));
const ownerOr = mongoUserIdEqualityFilter(
"owner.userId",
currentUserId,
};
const partyBlameOr = buildBlamePartyAccessOrConditions(
actorCtx,
linkedUserIds,
);
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),
const idVariants = collectUserIdVariants(...linkedUserIds);
const ownerOr = buildOwnerUserIdAccessFilter(idVariants);
// damagedPartyUserId filter — direct match without blame lookup
const damagedOr =
idVariants.length === 1
? { damagedPartyUserId: idVariants[0] }
: idVariants.length > 1
? { damagedPartyUserId: { $in: idVariants } }
: null;
// Build snapshot-party query: claim.snapshot.parties.person.userId
// is the most direct and reliable path — it's copied from the blame
// at claim-creation time and always has the correct damaged-party userId.
const snapshotPartyFilter =
idVariants.length === 1
? { "snapshot.parties.person.userId": idVariants[0] }
: idVariants.length > 1
? { "snapshot.parties.person.userId": { $in: idVariants } }
: null;
const [ownerClaims, damagedDirectClaims, snapshotClaims, candidateBlames] =
await Promise.all([
ownerOr
? this.claimCaseDbService.find(ownerOr as any, { lean: true })
: Promise.resolve([]),
damagedOr
? this.claimCaseDbService.find(damagedOr as any, { lean: true })
: Promise.resolve([]),
snapshotPartyFilter
? this.claimCaseDbService.find(snapshotPartyFilter as any, {
lean: true,
})
: Promise.resolve([]),
partyBlameOr.length
? this.blameRequestDbService.find(
partyBlameOr.length === 1
? (partyBlameOr[0] as any)
: ({ $or: partyBlameOr } as any),
{
lean: true,
select: "type parties expert.decision blameStatus",
},
)
: Promise.resolve([]),
]);
// Any blame where the user is a party (not just damaged) surfaces
// the linked claim. Mutation guards protect against acting as the
// wrong party; viewing is intentionally open to all parties.
const partyBlameIds = (candidateBlames as any[]).map((b) => b._id);
const damagedClaims =
partyBlameIds.length > 0
? await this.claimCaseDbService.find(
{ blameRequestId: { $in: partyBlameIds } },
{ lean: true },
);
)
: [];
// Merge: owner claims + blame-resolved damaged claims + snapshot-party claims
// snapshotClaims may include guilty-owner claims too — filter them out here.
const snapshotDamagedClaims = (snapshotClaims as any[]).filter((c) => {
if (!c?.owner?.userId) return true; // unknown owner: allow
const ownerStr = String(c.owner.userId);
// Exclude if the user IS the owner (already in ownerClaims)
return !linkedUserIds.some((id) => id === ownerStr);
});
const byId = new Map<string, any>();
for (const c of [
...(ownerClaims as any[]),
...(damagedDirectClaims as any[]),
...(damagedClaims as any[]),
...snapshotDamagedClaims,
]) {
byId.set(String(c._id), c);
}
claims = [...byId.values()];
}
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),