import { BadRequestException, Injectable, Logger, } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { promises as fs } from "node:fs"; import { Model, Types } from "mongoose"; import { ClientService, MediaKind, MediaLimits, } from "src/client/client.service"; import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema"; import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema"; /** * Shape we expect from a multer-uploaded file. We type loosely because two * different multer interceptors are in play (`FileInterceptor` and * `FileFieldsInterceptor`) and we don't want to drag `Express.Multer.File` * typings into a service file. */ type UploadedFileLike = | (Express.Multer.File & { path?: string }) | undefined | null; /** * Resolves the relevant client for a blame/claim file and enforces the * client's configured byte bounds against an uploaded file. On violation it * deletes the offending file from disk (best-effort) and throws a * `BadRequestException` carrying a structured payload the frontend can * branch on. * * Client resolution: * - Blame endpoints: any party on the `BlameRequest` that has * `person.clientId` set. We pick the first one we find. * - Claim endpoints: `ClaimCase.owner.clientId`. * * When no clientId can be resolved (early steps, malformed ids, missing * documents), the helper falls back to the system defaults declared in * `DEFAULT_MEDIA_LIMITS` so the gate is still enforced. */ @Injectable() export class MediaPolicyService { private readonly logger = new Logger(MediaPolicyService.name); constructor( @InjectModel(BlameRequest.name) private readonly blameRequestModel: Model, @InjectModel(ClaimCase.name) private readonly claimCaseModel: Model, private readonly clientService: ClientService, ) {} /** * Enforce limits for a file uploaded against a `BlameRequest` (V2). * Safe to call with a `null`/`undefined` file — it just no-ops, leaving * "file required" errors to be raised by the existing service handlers. */ async assertForBlame( file: UploadedFileLike, blameRequestId: string, kind: MediaKind, ): Promise { if (!file) return; const clientId = await this.resolveBlameClientId(blameRequestId); await this.assertWithClient(file, clientId, kind); } /** * Enforce limits for a file uploaded against a `ClaimCase` (V2). * Safe to call with a `null`/`undefined` file. */ async assertForClaim( file: UploadedFileLike, claimRequestId: string, kind: MediaKind, ): Promise { if (!file) return; const clientId = await this.resolveClaimClientId(claimRequestId); await this.assertWithClient(file, clientId, kind); } /** * Validate a bag of files (as produced by `FileFieldsInterceptor`) against * the blame file's client. Each field key is mapped to a `MediaKind` via * `fieldKindMap`; unmapped keys are ignored. On the first violation we * delete *every* provided file (the request is being rejected anyway) and * throw. */ async assertForBlameMany( files: Partial>, blameRequestId: string, fieldKindMap: Record, ): Promise { const provided = this.flattenProvidedFiles(files); if (provided.length === 0) return; const clientId = await this.resolveBlameClientId(blameRequestId); await this.assertManyWithClient(provided, clientId, fieldKindMap); } /** * Same as `assertForBlameMany` but for claim files. Use this when (and * if) a multi-field claim upload endpoint is added; today's claim * controllers only upload one file at a time. */ async assertForClaimMany( files: Partial>, claimRequestId: string, fieldKindMap: Record, ): Promise { const provided = this.flattenProvidedFiles(files); if (provided.length === 0) return; const clientId = await this.resolveClaimClientId(claimRequestId); await this.assertManyWithClient(provided, clientId, fieldKindMap); } // -- internal helpers --------------------------------------------------- private async assertWithClient( file: NonNullable, clientId: Types.ObjectId | string | null, kind: MediaKind, ): Promise { const limits = await this.clientService.getMediaLimits(clientId, kind); const violation = this.checkLimits(file, limits, kind); if (!violation) return; await this.tryDelete(file?.path); throw new BadRequestException(violation); } private async assertManyWithClient( provided: Array<{ field: TKeys; file: NonNullable }>, clientId: Types.ObjectId | string | null, fieldKindMap: Record, ): Promise { const limitsCache = new Map(); const getLimits = async (kind: MediaKind) => { if (!limitsCache.has(kind)) { limitsCache.set( kind, await this.clientService.getMediaLimits(clientId, kind), ); } return limitsCache.get(kind) as MediaLimits; }; for (const { field, file } of provided) { const kind = fieldKindMap[field]; if (!kind) continue; const limits = await getLimits(kind); const violation = this.checkLimits(file, limits, kind, field); if (!violation) continue; // The request is being rejected — clean up all uploaded files so we // don't leave orphans on disk. await Promise.all(provided.map(({ file: f }) => this.tryDelete(f?.path))); throw new BadRequestException(violation); } } private checkLimits( file: NonNullable, { minBytes, maxBytes }: MediaLimits, kind: MediaKind, field?: string, ): Record | null { const size = Number(file.size ?? 0); if (!Number.isFinite(size)) { return { code: "MEDIA_SIZE_UNKNOWN", kind, ...(field ? { field } : {}), message: `Could not determine the size of the uploaded ${kind} file.`, }; } if (size < minBytes) { return { code: "MEDIA_TOO_SMALL", kind, ...(field ? { field } : {}), sizeBytes: size, minBytes, maxBytes, message: `Uploaded ${kind} is too small (${size} bytes). ` + `Minimum allowed for this client is ${minBytes} bytes.`, }; } if (size > maxBytes) { return { code: "MEDIA_TOO_LARGE", kind, ...(field ? { field } : {}), sizeBytes: size, minBytes, maxBytes, message: `Uploaded ${kind} is too large (${size} bytes). ` + `Maximum allowed for this client is ${maxBytes} bytes.`, }; } return null; } private flattenProvidedFiles( files: Partial>, ): Array<{ field: TKeys; file: NonNullable }> { const out: Array<{ field: TKeys; file: NonNullable }> = []; if (!files) return out; for (const key of Object.keys(files) as TKeys[]) { for (const f of files[key] || []) { if (f) out.push({ field: key, file: f }); } } return out; } private async resolveBlameClientId( blameRequestId: string, ): Promise { if (!blameRequestId || !Types.ObjectId.isValid(blameRequestId)) return null; const blame = (await this.blameRequestModel .findById(blameRequestId) .select("parties") .lean() .exec()) as { parties?: Array<{ person?: { clientId?: Types.ObjectId } }> } | null; if (!blame?.parties?.length) return null; for (const party of blame.parties) { const clientId = party?.person?.clientId; if (clientId) return clientId; } return null; } private async resolveClaimClientId( claimRequestId: string, ): Promise { if (!claimRequestId || !Types.ObjectId.isValid(claimRequestId)) return null; const claim = (await this.claimCaseModel .findById(claimRequestId) .select("owner") .lean() .exec()) as { owner?: { clientId?: Types.ObjectId } } | null; return claim?.owner?.clientId ?? null; } private async tryDelete(filePath?: string): Promise { if (!filePath) return; try { await fs.unlink(filePath); } catch (err) { // Best-effort cleanup — log and continue. The HTTP response will // already carry the violation details for the client. this.logger.warn( `MediaPolicyService: failed to delete file at ${filePath}: ${ err instanceof Error ? err.message : String(err) }`, ); } } }