mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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).
390 lines
13 KiB
TypeScript
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 };
|
|
}
|
|
}
|