Files
edr-platform/apps/edr-freight-api/src/modules/files/files.service.ts
Nathnael fd5aedcec7 feat(companies): per-document change requests and resubmission review queue
Two review-workflow gaps for freight customer onboarding:

Request for change per document. Backoffice can now flag a single uploaded
document (company document, profile licence, or POA delegation letter) with a
note the customer sees, instead of rejecting the whole role over it. Adds
review_status/review_note/reviewed_by/reviewed_at to freight.files (migration
AddFileReviewStatus, partial index for the gate), a POST
documents/:fileId/request-change endpoint, the backoffice action + modal, and a
portal banner/badge so the customer knows what to re-upload. Re-uploading clears
the flag. Approving a role is blocked while any of its documents has an open
correction; the gate check and the status write share a pessimistic write lock
on the company row (as does the change-request write) so a correction can never
slip in between the check and the profile going Active.

Resubmission is visible to reviewers. When a customer resubmits a rejected role
or amends a change request, backoffice staff are notified (allBackoffice inbox
item, deep-linked to the customer) and the resubmission surfaces in a new
"Pending changes" list view + KPI, since such companies are status = active and
never matched the pending-approval filter.
2026-07-21 07:34:40 +00:00

294 lines
9.6 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
export interface CreateFileInput {
resourceId: string;
resource: string;
code: string;
file: Express.Multer.File;
}
/**
* Make a filename safe to use as a MinIO object-key segment: collapse runs of
* spaces/unsafe characters to a single underscore while keeping the dot before
* the extension. Prevents percent-encoding mismatches between the stored URL
* and the actual object key.
*/
function sanitizeObjectName(name: string): string {
return name
.normalize("NFKD")
.replace(/[^\w.\-]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_+|_+$/g, "");
}
@Injectable()
export class FilesService {
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
// route). This is deliberately BROADER than the driver controller's strict
// images+pdf Multer filter, because the same method also stores generated
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
// permitting every business-document type these flows legitimately upload.
// No file-upload-settings row governs raw byte size, so the cap is a sane,
// generous default that won't reject large scanned documents.
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
private static readonly ALLOWED_UPLOAD_MIME = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/heic",
"image/tiff",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"text/plain",
]);
constructor(
private readonly filesRepository: FilesRepository,
private readonly minioService: MinioService,
) {}
async upload(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code, file } = input;
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
}
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
throw new BadRequestException(
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
);
}
// Keep the object key URL-safe so it survives the round-trip through the
// stored URL (spaces/unicode in the original name would otherwise be
// percent-encoded in the URL and no longer match the MinIO key). The
// human-readable name is preserved separately on the record below.
const safeName = sanitizeObjectName(file.originalname);
// The random segment is load-bearing, not decoration. `Date.now()` alone is
// NOT unique across a batch: callers upload with Promise.all, every callback
// runs to its first await in the same tick, so they all read the same
// millisecond. Two files with one name in one batch — e.g. pasting two
// screenshots, which browsers both call "image.png" — would build identical
// keys, and the second putObject would overwrite the first while both rows
// persisted pointing at the same object.
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
const url = await this.minioService.uploadFile(
objectName,
file.buffer,
file.mimetype,
);
return this.filesRepository.create({
resourceId,
resource,
code,
name: file.originalname,
url,
size: file.size,
mimeType: file.mimetype,
});
}
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code } = input;
await this.filesRepository.deleteByCode(resourceId, resource, code);
return this.upload(input);
}
async deleteByCode(
resourceId: string,
resource: string,
code: string,
): Promise<void> {
await this.filesRepository.deleteByCode(resourceId, resource, code);
}
async uploadMany(
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<FileRecord[]> {
return Promise.all(
files.map((file) =>
this.upload({ resourceId, resource, code: file.fieldname, file }),
),
);
}
/**
* Attach already-stored files (e.g. a company profile's onboarding documents)
* to a resource by reference — creates FileRecord rows pointing at the existing
* object-storage URLs, without re-uploading bytes. The snapshot is fixed at call
* time, so later changes to the source documents never alter what was attached.
*/
async attachExistingFiles(
resourceId: string,
resource: string,
files: Array<{
code: string;
name: string;
url: string;
size: number;
mimeType?: string;
}>,
): Promise<FileRecord[]> {
return Promise.all(
files.map((f) =>
this.filesRepository.create({
resourceId,
resource,
code: f.code,
name: f.name,
url: f.url,
size: f.size,
mimeType: f.mimeType ?? "application/octet-stream",
}),
),
);
}
async findById(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id);
if (!record) throw new NotFoundException(`File ${id} not found`);
return record;
}
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
* reason can never outlive the request it explained.
*/
async setReviewStatus(
id: string,
status: FileReviewStatus,
note: string | null,
reviewerId?: string,
): Promise<FileRecord> {
const record = await this.findById(id);
const updated = await this.filesRepository.update(record.id, {
reviewStatus: status,
reviewNote: status === "change_requested" ? (note ?? null) : null,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
});
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Drop any reviewer verdict from a document, returning it to "not reviewed".
* Called when a customer re-uploads: the new bytes have not been looked at, so
* carrying the old `change_requested` forward would keep them blocked forever.
*/
async clearReview(id: string): Promise<void> {
await this.filesRepository.update(id, {
reviewStatus: null,
reviewNote: null,
reviewedBy: null,
reviewedAt: null,
});
}
/** Documents across these resources still awaiting a customer correction. */
findWithOpenChangeRequest(
resourceIds: string[],
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findWithOpenChangeRequest(
resourceIds,
resource,
);
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
/**
* Re-slot a stored file under a new `code` (e.g. promote a staged
* `business_license_pending` file to the live `business_license` code once a
* change request is approved). Bytes and URL are untouched.
*/
async setCode(id: string, code: string): Promise<void> {
await this.filesRepository.update(id, { code });
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).
*/
async findByResourceIdsGrouped(
resourceIds: string[],
resource: string,
): Promise<Map<string, FileRecord[]>> {
const records = await this.filesRepository.findByResourceIds(
resourceIds,
resource,
);
const grouped = new Map<string, FileRecord[]>();
for (const record of records) {
const bucket = grouped.get(record.resourceId);
if (bucket) bucket.push(record);
else grouped.set(record.resourceId, [record]);
}
return grouped;
}
/**
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
* `url` is an un-signed object path that a browser cannot fetch directly;
* callers that expose files for preview/download must sign them first.
*/
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
return this.minioService.getSignedUrl(objectName, expirySeconds);
}
async findByCode(
resourceId: string,
resource: string,
code: string,
): Promise<FileRecord> {
const record = await this.filesRepository.findByCode(
resourceId,
resource,
code,
);
if (!record)
throw new NotFoundException(
`File with code "${code}" not found for ${resource} ${resourceId}`,
);
return record;
}
async streamById(
id: string,
): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
}
}