This commit is contained in:
SepehrYahyaee
2026-05-12 11:00:18 +03:30
parent e89cf107ff
commit f75e6a4453
9 changed files with 451 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { ClientModule } from "src/client/client.module";
import {
ClaimCase,
ClaimCaseSchema,
} from "src/claim-request-management/entites/schema/claim-cases.schema";
import {
BlameRequest,
BlameRequestSchema,
} from "src/request-management/entities/schema/blame-cases.schema";
import { MediaPolicyService } from "./media-policy.service";
/**
* Stand-alone module so we can inject `MediaPolicyService` into both the
* blame and claim V2 controllers without dragging the entire
* `RequestManagementModule` / `ClaimRequestManagementModule` graph along
* (which would risk a circular dependency).
*
* The schemas are registered directly via `MongooseModule.forFeature` —
* Mongoose deduplicates models behind the scenes so a second registration
* does not create a separate model.
*/
@Module({
imports: [
ClientModule,
MongooseModule.forFeature([
{ name: BlameRequest.name, schema: BlameRequestSchema },
{ name: ClaimCase.name, schema: ClaimCaseSchema },
]),
],
providers: [MediaPolicyService],
exports: [MediaPolicyService],
})
export class MediaPolicyModule {}

View File

@@ -0,0 +1,270 @@
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<BlameRequest>,
@InjectModel(ClaimCase.name)
private readonly claimCaseModel: Model<ClaimCase>,
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<void> {
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<void> {
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<TKeys extends string>(
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
blameRequestId: string,
fieldKindMap: Record<TKeys, MediaKind | undefined>,
): Promise<void> {
const provided = this.flattenProvidedFiles<TKeys>(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<TKeys extends string>(
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
claimRequestId: string,
fieldKindMap: Record<TKeys, MediaKind | undefined>,
): Promise<void> {
const provided = this.flattenProvidedFiles<TKeys>(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<UploadedFileLike>,
clientId: Types.ObjectId | string | null,
kind: MediaKind,
): Promise<void> {
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<TKeys extends string>(
provided: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }>,
clientId: Types.ObjectId | string | null,
fieldKindMap: Record<TKeys, MediaKind | undefined>,
): Promise<void> {
const limitsCache = new Map<MediaKind, MediaLimits>();
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<UploadedFileLike>,
{ minBytes, maxBytes }: MediaLimits,
kind: MediaKind,
field?: string,
): Record<string, unknown> | 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<TKeys extends string>(
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
): Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> {
const out: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> = [];
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<Types.ObjectId | null> {
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<Types.ObjectId | null> {
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<void> {
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)
}`,
);
}
}
}