Files
edr-platform/apps/edr-freight-api/src/modules/files/files.service.ts
Nathnael 4f81a0bbb8 feat(freight): non-terminal change-request review + unified customer timeline
Backoffice can now "Request changes" on a pending settings change
request without rejecting it outright: a new ChangesRequested status
keeps the row open so the customer's next edit appends into the same
request instead of starting a fresh cycle, and the reviewer's note
persists across that round instead of being cleared on resubmit.

Version History and Review History (previously two separate,
differently-shaped lists) are merged into one chronological timeline
under a new History tab, including document changes shown as a real
previous-vs-current diff (both files openable).

Bug fixes surfaced while wiring this up:
- Replacing a single-file document slot left the old file live
  alongside the new one instead of retiring it (customer settings +
  onboarding uploads).
- The "previous" file in a document diff 404'd once superseded —
  the preview route now also matches soft-deleted records.
- A document replace was recorded twice in the timeline (once at
  upload, once again at change-request approval).
2026-07-31 14:12:30 +00:00

390 lines
13 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;
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
title?: string | null;
visibleToCustomer?: boolean;
uploadedByUserId?: string | null;
uploadedByName?: string | null;
}
/**
* 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,
title: input.title ?? null,
visibleToCustomer: input.visibleToCustomer ?? false,
uploadedByUserId: input.uploadedByUserId ?? null,
uploadedByName: input.uploadedByName ?? null,
});
}
/**
* Edit the uploader-authored metadata of a stored file (title, customer
* visibility). Bytes are untouched — callers replacing content upload a new
* record instead.
*/
async updateMeta(
id: string,
patch: { title?: string; visibleToCustomer?: boolean },
): Promise<FileRecord> {
const updated = await this.filesRepository.update(id, patch);
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who
* swapped it and why, which is what the version history shows.
*/
async upsertByCode(
input: CreateFileInput,
replacedBy?: { userId?: string | null; reason?: string | null },
): Promise<FileRecord> {
const { resourceId, resource, code } = input;
await this.filesRepository.deleteByCode(
resourceId,
resource,
code,
replacedBy,
);
return this.upload(input);
}
/**
* Every stored version of one document, newest first. `isCurrent` marks the
* live row; the rest are superseded uploads kept for audit.
*/
async versionHistory(
resourceId: string,
resource: string,
code: string,
): Promise<
Array<{
id: string;
name: string;
url: string;
size: number;
mimeType: string;
uploadedAt: string;
isCurrent: boolean;
replacedAt: string | null;
replacedByUserId: string | null;
replaceReason: string | null;
}>
> {
const rows = await this.filesRepository.findVersionHistory(
resourceId,
resource,
code,
);
return rows.map((row) => ({
id: row.id,
name: row.name,
url: row.url,
size: row.size,
mimeType: row.mimeType,
uploadedAt: row.createdAt.toISOString(),
isCurrent: row.deletedAt == null,
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
replacedByUserId: row.replacedByUserId,
replaceReason: row.replaceReason,
}));
}
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;
}
/**
* Same as {@link findById}, but also matches a soft-deleted record — a
* superseded document (replaced via a single-file slot, or a resolved
* license/PoA swap) is exactly this: gone from every live listing, but its
* id is still handed to reviewers in the change-request/version-history
* diff so they can open the "previous" file for comparison. Only the
* preview/download route should use this; every other caller wants the
* default (soft-deleted = not found).
*/
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id, {
withDeleted: true,
});
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,
opts: { includeDeleted?: boolean } = {},
): Promise<{ stream: Readable; record: FileRecord }> {
const record = opts.includeDeleted
? await this.findByIdIncludingDeleted(id)
: await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
}
}