From 3c863bb90c60e30ac31b9d2e81e6a29175f6a4ed Mon Sep 17 00:00:00 2001 From: Sepehr Yahyaee Date: Fri, 19 Jun 2026 09:50:42 +0330 Subject: [PATCH] Fixed users not being able to view their files --- src/auth/guards/global.guard.ts | 24 +- src/auth/guards/role.guard.ts | 9 +- .../claim-request-management.service.ts | 516 ++++++++++++------ .../entites/schema/claim-cases.schema.ts | 8 + src/helpers/blame-damaged-party.spec.ts | 58 ++ src/helpers/blame-damaged-party.ts | 121 ++++ src/helpers/iran-mobile.ts | 14 +- src/helpers/party-access-queries.spec.ts | 27 + src/helpers/party-access-queries.ts | 59 +- src/helpers/user-access-resolver.ts | 52 ++ .../request-management.service.ts | 72 ++- .../entities/db-service/user.db.service.ts | 7 + 12 files changed, 745 insertions(+), 222 deletions(-) create mode 100644 src/helpers/blame-damaged-party.spec.ts create mode 100644 src/helpers/blame-damaged-party.ts create mode 100644 src/helpers/user-access-resolver.ts diff --git a/src/auth/guards/global.guard.ts b/src/auth/guards/global.guard.ts index 80e3b8d..b709d37 100644 --- a/src/auth/guards/global.guard.ts +++ b/src/auth/guards/global.guard.ts @@ -8,6 +8,12 @@ import { JwtService } from "@nestjs/jwt"; import { Request } from "express"; import { RoleEnum } from "src/Types&Enums/role.enum"; +const GLOBAL_GUARD_ROLES = new Set([ + RoleEnum.USER, + RoleEnum.FIELD_EXPERT, + RoleEnum.REGISTRAR, +]); + @Injectable() export class GlobalGuard implements CanActivate { constructor(private readonly jwtService: JwtService) {} @@ -17,7 +23,7 @@ export class GlobalGuard implements CanActivate { const token = this.extractTokenFromHeader(request); if (!token) { - throw new UnauthorizedException(); + throw new UnauthorizedException("Missing Bearer token"); } try { @@ -25,17 +31,19 @@ export class GlobalGuard implements CanActivate { secret: `${process.env.JWT_SECRET}`, }); - if ( - payload.role !== RoleEnum.USER && - payload.role !== RoleEnum.FIELD_EXPERT - ) { - throw new UnauthorizedException(); + if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) { + throw new UnauthorizedException( + `Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`, + ); } request.user = payload; request.identity = request.user; - } catch { - throw new UnauthorizedException(); + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException("Invalid or expired token"); } return true; } diff --git a/src/auth/guards/role.guard.ts b/src/auth/guards/role.guard.ts index 36816b5..36c3bf6 100644 --- a/src/auth/guards/role.guard.ts +++ b/src/auth/guards/role.guard.ts @@ -5,16 +5,19 @@ import { Reflector } from "@nestjs/core"; export class RolesGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - // get the roles required const roles = this.reflector.getAllAndOverride("role", [ context.getHandler(), context.getClass(), ]); - if (!roles) { + if (!roles?.length) { return false; } const request = context.switchToHttp().getRequest(); - const userRoles = request.user?.role?.split(","); + const role = request.user?.role; + if (!role) { + return false; + } + const userRoles = String(role).split(","); return this.validateRoles(roles, userRoles); } diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 231203d..994a9b8 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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 { - 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 { + 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[] = []; - 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(); + 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(), diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 3426f21..9832942 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -116,6 +116,14 @@ export class ClaimCase { @Prop({ type: Types.ObjectId, index: true }) initiatedByFieldExpertId?: Types.ObjectId; + /** + * The damaged party's userId, resolved from the blame at claim-creation time. + * Stored here so view/list access does not require an extra blame lookup. + * For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party. + */ + @Prop({ type: Types.ObjectId, index: true }) + damagedPartyUserId?: Types.ObjectId; + @Prop({ type: ClaimWorkflowSchema, default: () => ({}) }) workflow?: ClaimWorkflow; diff --git a/src/helpers/blame-damaged-party.spec.ts b/src/helpers/blame-damaged-party.spec.ts new file mode 100644 index 0000000..9502b0e --- /dev/null +++ b/src/helpers/blame-damaged-party.spec.ts @@ -0,0 +1,58 @@ +import { Types } from "mongoose"; +import { partyPersonMatchesUser } from "./iran-mobile"; +import { + blameDamagedPartyMatchesUser, + resolveDamagedPartyPerson, + resolveGuiltyPartyUserId, +} from "./blame-damaged-party"; + +describe("blame damaged party helpers", () => { + const guiltyId = new Types.ObjectId(); + const damagedId = new Types.ObjectId(); + + const thirdPartyBlame = { + type: "THIRD_PARTY", + expert: { decision: { guiltyPartyId: guiltyId } }, + parties: [ + { role: "FIRST", person: { userId: guiltyId, phoneNumber: "09111111111" } }, + { role: "SECOND", person: { userId: damagedId, phoneNumber: "09912356917" } }, + ], + }; + + it("resolves guilty party id from expert decision", () => { + expect(resolveGuiltyPartyUserId(thirdPartyBlame)).toBe(String(guiltyId)); + }); + + it("resolves damaged party person as non-guilty", () => { + expect(resolveDamagedPartyPerson(thirdPartyBlame)).toEqual( + thirdPartyBlame.parties[1].person, + ); + }); + + it("matches damaged party by userId even when claim.owner is guilty", () => { + expect( + blameDamagedPartyMatchesUser( + thirdPartyBlame, + { sub: String(damagedId), username: "09912356917" }, + [], + ), + ).toBe(true); + expect( + blameDamagedPartyMatchesUser( + thirdPartyBlame, + { sub: String(guiltyId), username: "09111111111" }, + [], + ), + ).toBe(false); + }); + + it("matches damaged party by phone when userId differs (duplicate accounts)", () => { + expect( + partyPersonMatchesUser( + { userId: damagedId, phoneNumber: "9123456789" }, + { sub: "other-user-id", username: "09123456789" }, + [], + ), + ).toBe(true); + }); +}); diff --git a/src/helpers/blame-damaged-party.ts b/src/helpers/blame-damaged-party.ts new file mode 100644 index 0000000..e1666b8 --- /dev/null +++ b/src/helpers/blame-damaged-party.ts @@ -0,0 +1,121 @@ +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { partyPersonMatchesUser } from "./iran-mobile"; + +/** Resolve guilty party user id from blame case (expert decision → statement → AGREED first party). */ +export function resolveGuiltyPartyUserId(blame: { + parties?: Array<{ + role?: string; + person?: { userId?: unknown }; + statement?: { admitsGuilt?: boolean }; + }>; + expert?: { decision?: { guiltyPartyId?: unknown } }; + blameStatus?: string; +}): string | null { + const parties = blame?.parties ?? []; + const decisionId = blame?.expert?.decision?.guiltyPartyId; + if (decisionId != null && String(decisionId)) { + return String(decisionId); + } + + const admitsGuilt = parties.find((p) => p.statement?.admitsGuilt === true); + if (admitsGuilt?.person?.userId != null) { + return String(admitsGuilt.person.userId); + } + + if (blame?.blameStatus === "AGREED") { + const first = parties.find((p) => p.role === "FIRST"); + if (first?.person?.userId != null) { + return String(first.person.userId); + } + } + + return null; +} + +/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */ +export function resolveDamagedPartyPerson(blame: { + type?: string; + parties?: Array<{ + role?: string; + person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown }; + statement?: { admitsGuilt?: boolean; claimsDamage?: boolean }; + }>; + expert?: { decision?: { guiltyPartyId?: unknown } }; + blameStatus?: string; +}): { userId?: unknown; phoneNumber?: string; clientId?: unknown } | null { + const parties = blame?.parties ?? []; + if (!parties.length) return null; + + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; + + if (isCarBody) { + const first = parties.find((p) => p.role === "FIRST"); + return first?.person ?? null; + } + + const guiltyId = resolveGuiltyPartyUserId(blame); + if (!guiltyId) { + const claimsDamage = parties.find((p) => p.statement?.claimsDamage === true); + return claimsDamage?.person ?? null; + } + + const damagedParty = parties.find( + (p) => + p.person?.userId != null && String(p.person.userId) !== String(guiltyId), + ); + if (damagedParty?.person) return damagedParty.person; + + const nonGuilty = parties.find( + (p) => p.person && String(p.person.userId ?? "") !== String(guiltyId), + ); + return nonGuilty?.person ?? null; +} + +export function blameDamagedPartyMatchesUser( + blame: Parameters[0], + user: { sub?: string; username?: string }, + linkedUserIdStrings: string[] = [], +): boolean { + const person = resolveDamagedPartyPerson(blame); + if (!person) return false; + return partyPersonMatchesUser(person, user, linkedUserIdStrings); +} + +/** Blame party that owns the claim row (`owner.userId` = guilty for THIRD_PARTY, first party for CAR_BODY). */ +export function resolveClaimOwnerParty(blame: { + type?: string; + parties?: Array<{ + role?: string; + person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown }; + statement?: { admitsGuilt?: boolean }; + }>; + expert?: { decision?: { guiltyPartyId?: unknown } }; + blameStatus?: string; +}): { + role?: string; + person?: { userId?: unknown; phoneNumber?: string; clientId?: unknown }; +} | null { + const parties = blame?.parties ?? []; + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; + + if (isCarBody) { + return parties.find((p) => p.role === "FIRST") ?? null; + } + + const guiltyId = resolveGuiltyPartyUserId(blame); + if (!guiltyId) return null; + return ( + parties.find( + (p) => p.person?.userId != null && String(p.person.userId) === guiltyId, + ) ?? null + ); +} + +export function resolveDamagedPartyUserId( + blame: Parameters[0], +): string | null { + const person = resolveDamagedPartyPerson(blame); + return person?.userId != null ? String(person.userId) : null; +} diff --git a/src/helpers/iran-mobile.ts b/src/helpers/iran-mobile.ts index 9e5dde7..02de4e1 100644 --- a/src/helpers/iran-mobile.ts +++ b/src/helpers/iran-mobile.ts @@ -52,15 +52,19 @@ export function partyPersonMatchesUser( | null | undefined, user: { sub?: string; username?: string } | null | undefined, + linkedUserIdStrings: string[] = [], ): boolean { if (!person || !user) return false; - if ( - person.userId != null && - user.sub && - String(person.userId) === String(user.sub) - ) { + + const userIds = new Set(); + if (user.sub) userIds.add(String(user.sub)); + for (const id of linkedUserIdStrings) { + if (id) userIds.add(String(id)); + } + if (person.userId != null && userIds.has(String(person.userId))) { return true; } + if (!person.phoneNumber || !user.username) return false; const stored = normalizeIranMobile(person.phoneNumber) ?? person.phoneNumber; diff --git a/src/helpers/party-access-queries.spec.ts b/src/helpers/party-access-queries.spec.ts index 1b2faad..9eb5120 100644 --- a/src/helpers/party-access-queries.spec.ts +++ b/src/helpers/party-access-queries.spec.ts @@ -37,6 +37,22 @@ describe("party access queries", () => { }, }); }); + + it("includes linked duplicate-account user ids", () => { + const linked = new Types.ObjectId(); + const or = buildBlamePartyAccessOrConditions( + { sub: String(userId), username: "09123456789" }, + [String(linked)], + ); + expect(or[0]).toEqual({ + $or: expect.arrayContaining([ + { "parties.person.userId": String(userId) }, + { "parties.person.userId": userId }, + { "parties.person.userId": String(linked) }, + { "parties.person.userId": linked }, + ]), + }); + }); }); describe("partyPersonMatchesUser", () => { @@ -57,5 +73,16 @@ describe("party access queries", () => { ), ).toBe(true); }); + + it("matches linked duplicate-account user ids", () => { + const linked = new Types.ObjectId(); + expect( + partyPersonMatchesUser( + { userId: linked, phoneNumber: "09111111111" }, + { sub: String(userId), username: "09999999999" }, + [String(linked)], + ), + ).toBe(true); + }); }); }); diff --git a/src/helpers/party-access-queries.ts b/src/helpers/party-access-queries.ts index 17d64d4..7a0ef56 100644 --- a/src/helpers/party-access-queries.ts +++ b/src/helpers/party-access-queries.ts @@ -2,6 +2,36 @@ import { Types } from "mongoose"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { iranMobileLookupVariants } from "./iran-mobile"; +/** ObjectId + string variants for one or more user ids (legacy duplicate accounts). */ +export function collectUserIdVariants( + ...ids: (string | Types.ObjectId | undefined | null)[] +): unknown[] { + const seen = new Set(); + const out: unknown[] = []; + for (const id of ids) { + if (id == null || id === "") continue; + const s = String(id); + if (seen.has(s)) continue; + seen.add(s); + out.push(s); + if (Types.ObjectId.isValid(s)) { + out.push(new Types.ObjectId(s)); + } + } + return out; +} + +export function buildUserIdFieldOrFilter( + fieldPath: string, + idVariants: unknown[], +): Record | null { + if (!idVariants.length) return null; + if (idVariants.length === 1) { + return { [fieldPath]: idVariants[0] }; + } + return { $or: idVariants.map((id) => ({ [fieldPath]: id })) }; +} + /** * Mongo filter matching a user id field stored as ObjectId or legacy string. */ @@ -9,26 +39,23 @@ export function mongoUserIdEqualityFilter( fieldPath: string, userSub: string, ): Record | null { - if (!userSub) return null; - const variants: unknown[] = [userSub]; - if (Types.ObjectId.isValid(userSub)) { - variants.push(new Types.ObjectId(userSub)); - } - if (variants.length === 1) { - return { [fieldPath]: variants[0] }; - } - return { $or: variants.map((v) => ({ [fieldPath]: v })) }; + return buildUserIdFieldOrFilter(fieldPath, collectUserIdVariants(userSub)); } /** `$or` branches: user is a party on a blame case (by userId and/or phone). */ -export function buildBlamePartyAccessOrConditions(user: { - sub?: string; - username?: string; -}): Record[] { +export function buildBlamePartyAccessOrConditions( + user: { + sub?: string; + username?: string; + }, + linkedUserIdStrings: string[] = [], +): Record[] { const or: Record[] = []; - const userIdCond = user?.sub - ? mongoUserIdEqualityFilter("parties.person.userId", user.sub) - : null; + const idVariants = collectUserIdVariants(user.sub, ...linkedUserIdStrings); + const userIdCond = buildUserIdFieldOrFilter( + "parties.person.userId", + idVariants, + ); if (userIdCond) or.push(userIdCond); const phoneVariants = user?.username diff --git a/src/helpers/user-access-resolver.ts b/src/helpers/user-access-resolver.ts new file mode 100644 index 0000000..e7e7a4a --- /dev/null +++ b/src/helpers/user-access-resolver.ts @@ -0,0 +1,52 @@ +import { Types } from "mongoose"; +import { buildUserLookupByPhone } from "./iran-mobile"; +import { UserDbService } from "src/users/entities/db-service/user.db.service"; +import { collectUserIdVariants } from "./party-access-queries"; + +/** All Mongo user ids that may represent the same person (JWT sub + phone aliases). */ +export async function resolveLinkedUserIdStrings( + userDbService: UserDbService, + actor: { sub?: string; username?: string }, +): Promise { + const ids = new Set(); + if (actor?.sub) ids.add(String(actor.sub)); + + if (actor?.username?.trim()) { + const rows = await userDbService.findAllByPhone(actor.username); + for (const row of rows) { + const id = (row as { _id?: unknown })._id; + if (id != null) ids.add(String(id)); + } + } + + return [...ids]; +} + +export async function resolveLinkedUserIdVariants( + userDbService: UserDbService, + actor: { sub?: string; username?: string }, +): Promise { + const strings = await resolveLinkedUserIdStrings(userDbService, actor); + return collectUserIdVariants(...strings); +} + +export function ownerUserIdMatchesActor( + ownerUserId: unknown, + linkedUserIdStrings: string[], +): boolean { + if (ownerUserId == null || linkedUserIdStrings.length === 0) return false; + const owner = String(ownerUserId); + return linkedUserIdStrings.some((id) => id === owner); +} + +export function buildOwnerUserIdAccessFilter( + linkedUserIdVariants: unknown[], +): Record | null { + if (!linkedUserIdVariants.length) return null; + if (linkedUserIdVariants.length === 1) { + return { "owner.userId": linkedUserIdVariants[0] }; + } + return { + $or: linkedUserIdVariants.map((id) => ({ "owner.userId": id })), + }; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 6a76763..c6f18e0 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -97,7 +97,11 @@ import { normalizeIranMobile, partyPersonMatchesUser, } from "src/helpers/iran-mobile"; -import { buildBlamePartyAccessOrConditions } from "src/helpers/party-access-queries"; +import { + buildBlamePartyAccessOrConditions, + collectUserIdVariants, +} from "src/helpers/party-access-queries"; +import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; @Injectable() export class RequestManagementService { @@ -4562,9 +4566,9 @@ export class RequestManagementService { phone: string, otp: string, ): Promise => { - const user = await this.userDbService.findOne({ - $or: [{ username: phone }, { mobile: phone }], - }); + const user = await this.userDbService.findOne( + buildUserLookupByPhone(phone), + ); if (!user) throw new BadRequestException(`User not found for phone: ${phone}`); const u = user as any; @@ -4783,9 +4787,9 @@ export class RequestManagementService { // Verify the OTP and resolve the user id. const now = Date.now(); - const user = await this.userDbService.findOne({ - $or: [{ username: phone }, { mobile: phone }], - }); + const user = await this.userDbService.findOne( + buildUserLookupByPhone(phone), + ); if (!user) throw new BadRequestException(`User not found for phone: ${phone}`); const u = user as any; @@ -5029,7 +5033,14 @@ export class RequestManagementService { query: ListQueryV2Dto = {}, ): Promise { try { - const orConditions = buildBlamePartyAccessOrConditions(user); + const linkedUserIds = + user?.role === RoleEnum.USER || !user?.role + ? await resolveLinkedUserIdStrings(this.userDbService, user) + : []; + const orConditions = buildBlamePartyAccessOrConditions( + user, + linkedUserIds, + ); if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) { orConditions.push({ @@ -5043,6 +5054,34 @@ export class RequestManagementService { }); } + // Snapshot-party fallback: find blames via claims where this user appears + // in claim.snapshot.parties (the most reliable path for IN_PERSON flows). + const idVariants = collectUserIdVariants( + ...(linkedUserIds.length > 0 ? linkedUserIds : [user?.sub].filter(Boolean)), + ); + if (idVariants.length > 0) { + const snapshotFilter = + idVariants.length === 1 + ? { "snapshot.parties.person.userId": idVariants[0] } + : { "snapshot.parties.person.userId": { $in: idVariants } }; + const linkedClaims = (await this.claimCaseDbService.find( + snapshotFilter as any, + { lean: true, select: "blameRequestId owner.userId" }, + )) as any[]; + + for (const c of linkedClaims) { + if (!c.blameRequestId) continue; + // Exclude if this user IS the owner (guilty party) — they already show + // up via the claim-owner route; we only want the damaged party here. + const ownerStr = c.owner?.userId ? String(c.owner.userId) : null; + const isOwner = ownerStr + ? idVariants.some((id) => String(id) === ownerStr) + : false; + if (isOwner) continue; + orConditions.push({ _id: c.blameRequestId }); + } + } + if (orConditions.length === 0) { return { list: [], total: 0 }; } @@ -5067,7 +5106,7 @@ export class RequestManagementService { String(req.initiatedByRegistrarId) === String(user.sub)); const party = req.parties?.find((p: any) => - partyPersonMatchesUser(p?.person, user), + partyPersonMatchesUser(p?.person, user, linkedUserIds), ); const obj = req.toObject(); @@ -5332,6 +5371,10 @@ export class RequestManagementService { if (!req) { throw new NotFoundException("Request not found"); } + const linkedUserIds = + user?.role === RoleEnum.USER || !user?.role + ? await resolveLinkedUserIdStrings(this.userDbService, user) + : []; const isFieldExpertOwner = req.expertInitiated && req.initiatedByFieldExpertId && @@ -5342,7 +5385,9 @@ export class RequestManagementService { String(req.initiatedByRegistrarId) === String(user?.sub); const isParty = Array.isArray(req.parties) && - req.parties.some((p: any) => partyPersonMatchesUser(p?.person, user)); + req.parties.some((p: any) => + partyPersonMatchesUser(p?.person, user, linkedUserIds), + ); if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) { throw new ForbiddenException("You do not have access to this request"); } @@ -5392,7 +5437,7 @@ export class RequestManagementService { isFieldExpertOwner || isRegistrarOwner ? allParties : allParties.filter((p: any) => - partyPersonMatchesUser(p?.person, user), + partyPersonMatchesUser(p?.person, user, linkedUserIds), ); const parties = await Promise.all( visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)), @@ -5586,7 +5631,8 @@ export class RequestManagementService { role: PartyRole.FIRST, person: { userId: firstPartyUserId, - phoneNumber: formData.firstPartyPhoneNumber, + phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ?? + formData.firstPartyPhoneNumber, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver, insurerLicense: firstPartyPlate.insurerLicense, @@ -5771,7 +5817,7 @@ export class RequestManagementService { role, person: { userId, - phoneNumber, + phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber, nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, nationalCodeOfDriver: plateDto.nationalCodeOfDriver, insurerLicense: plateDto.insurerLicense, diff --git a/src/users/entities/db-service/user.db.service.ts b/src/users/entities/db-service/user.db.service.ts index f610135..6a430eb 100644 --- a/src/users/entities/db-service/user.db.service.ts +++ b/src/users/entities/db-service/user.db.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { FilterQuery, Model, UpdateQuery } from "mongoose"; +import { buildUserLookupByPhone } from "src/helpers/iran-mobile"; import { UserDocument, UserModel } from "../schema/user.schema"; @Injectable() @@ -17,6 +18,12 @@ export class UserDbService { return await this.userModel.findOne(user); } + async findAllByPhone(phone: string): Promise { + return (await this.userModel + .find(buildUserLookupByPhone(phone)) + .lean()) as UserModel[]; + } + async findPlate(plate: number) { return await this.userModel.find({ "plates.leftDigits": { $in: plate } }); }