import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; import { FanavaranClientKey, getFanavaranClientProfile, resolveFanavaranClientKey, } from "src/core/config/fanavaran-client.config"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; type LocationEntry = { id?: string; name?: string }; /** * Parsian V4/V5 FileMaker flows: resolve Fanavaran HTTP `Location` from * file-maker / file-reviewer `locations[].id`, and enforce maker/reviewer match. */ @Injectable() export class FanavaranLocationService { private readonly logger = new Logger(FanavaranLocationService.name); constructor( private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, ) {} isParsianClient(clientKey?: FanavaranClientKey): boolean { return (clientKey ?? resolveFanavaranClientKey()) === "parsian"; } extractLocationIds(locations?: LocationEntry[] | null): string[] { if (!Array.isArray(locations) || locations.length === 0) return []; const ids: string[] = []; for (const entry of locations) { const id = String(entry?.id ?? "").trim(); if (id && !ids.includes(id)) ids.push(id); } return ids; } primaryLocationId(locations?: LocationEntry[] | null): string | undefined { return this.extractLocationIds(locations)[0]; } /** * Maker primary location → reviewer primary → tenant auth.location. * Non-parsian or non-FileMaker flows always use the tenant default. */ async resolveBusinessLocation(input: { clientKey: FanavaranClientKey; isMadeByFileMaker?: boolean; fileMakerId?: string | null; fileReviewerId?: string | null; }): Promise { const defaultLocation = getFanavaranClientProfile(input.clientKey).auth .location; if (input.clientKey !== "parsian" || input.isMadeByFileMaker !== true) { return defaultLocation; } const makerLocation = await this.loadPrimaryLocationId(input.fileMakerId, "maker"); if (makerLocation) { this.logger.debug( `Fanavaran Location from file-maker id=${input.fileMakerId} → ${makerLocation}`, ); return makerLocation; } const reviewerLocation = await this.loadPrimaryLocationId( input.fileReviewerId, "reviewer", ); if (reviewerLocation) { this.logger.debug( `Fanavaran Location from file-reviewer id=${input.fileReviewerId} → ${reviewerLocation}`, ); return reviewerLocation; } this.logger.debug( `Fanavaran Location fallback to tenant default → ${defaultLocation}`, ); return defaultLocation; } /** * When both maker and reviewer have locations, they must share at least one id. * No-op for non-parsian deployments or when either side has no locations. */ async assertMakerReviewerLocationCompatible(input: { fileMakerId?: string | null; fileReviewerId?: string | null; clientKey?: FanavaranClientKey; }): Promise { if (!this.isParsianClient(input.clientKey)) { return; } const makerIds = await this.loadLocationIds(input.fileMakerId, "maker"); const reviewerIds = await this.loadLocationIds( input.fileReviewerId, "reviewer", ); if (makerIds.length === 0 || reviewerIds.length === 0) { return; } const shared = makerIds.some((id) => reviewerIds.includes(id)); if (!shared) { throw new ForbiddenException( "FileReviewer location must match the FileMaker location for this case.", ); } } async fileMakerBranchId( fileMakerId: string | null | undefined, ): Promise { if (!fileMakerId) return undefined; const doc = await this.fileMakerDbService.findById(String(fileMakerId)); const branchId = (doc as any)?.branchId; return branchId ? String(branchId) : undefined; } async fileReviewerBranchId( fileReviewerId: string | null | undefined, ): Promise { if (!fileReviewerId) return undefined; const doc = await this.fileReviewerDbService.findById( String(fileReviewerId), ); const branchId = (doc as any)?.branchId; return branchId ? String(branchId) : undefined; } /** * V4/V5 case visibility is branch-scoped by the FileMaker who created it. * Missing branch assignments are denied instead of widening visibility. */ async assertMakerReviewerBranchCompatible(input: { fileMakerId?: string | null; fileReviewerId?: string | null; caseBranchId?: string | null; }): Promise { const reviewerBranchId = await this.fileReviewerBranchId( input.fileReviewerId, ); const caseBranchId = (input.caseBranchId ? String(input.caseBranchId) : undefined) ?? (await this.fileMakerBranchId(input.fileMakerId)); if (!reviewerBranchId) { throw new ForbiddenException( "FileReviewer account is not assigned to a branch.", ); } if (!caseBranchId || caseBranchId !== reviewerBranchId) { throw new ForbiddenException( "This file belongs to another branch.", ); } } private async loadPrimaryLocationId( userId: string | null | undefined, kind: "maker" | "reviewer", ): Promise { const ids = await this.loadLocationIds(userId, kind); return ids[0]; } private async loadLocationIds( userId: string | null | undefined, kind: "maker" | "reviewer", ): Promise { if (!userId) return []; try { const doc = kind === "maker" ? await this.fileMakerDbService.findById(String(userId)) : await this.fileReviewerDbService.findById(String(userId)); return this.extractLocationIds((doc as any)?.locations); } catch { return []; } } }