fix(storage): route every file op through getStorageBackend()

Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction.  Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc.  Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.

Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend.  A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).

Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 18:41:02 +02:00
parent 312779c0c5
commit cf430d70c3
12 changed files with 121 additions and 86 deletions

View File

@@ -4,8 +4,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors'; import { errorResponse, ValidationError } from '@/lib/errors';
import { minioClient } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
const renameFolderSchema = z.object({ const renameFolderSchema = z.object({
newPath: z.string().min(1).max(500), newPath: z.string().min(1).max(500),
@@ -38,11 +37,14 @@ export const PATCH = withAuth(
const oldKey = `${ctx.portSlug}/${safeCurrent}${safeCurrent.endsWith('/') ? '' : '/'}`; const oldKey = `${ctx.portSlug}/${safeCurrent}${safeCurrent.endsWith('/') ? '' : '/'}`;
const newKey = `${ctx.portSlug}/${safeNew}${safeNew.endsWith('/') ? '' : '/'}`; const newKey = `${ctx.portSlug}/${safeNew}${safeNew.endsWith('/') ? '' : '/'}`;
// Create new marker, remove old // Create new marker, remove old. Routed through the active backend
await minioClient.putObject(env.MINIO_BUCKET, newKey, Buffer.alloc(0), 0, { // so filesystem mode doesn't crash.
'Content-Type': 'application/x-directory', const backend = await getStorageBackend();
await backend.put(newKey, Buffer.alloc(0), {
contentType: 'application/x-directory',
sizeBytes: 0,
}); });
await minioClient.removeObject(env.MINIO_BUCKET, oldKey); await backend.delete(oldKey);
return NextResponse.json({ data: { path: newKey } }); return NextResponse.json({ data: { path: newKey } });
} catch (error) { } catch (error) {
@@ -65,7 +67,7 @@ export const DELETE = withAuth(
} }
const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`; const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`;
await minioClient.removeObject(env.MINIO_BUCKET, folderKey); await (await getStorageBackend()).delete(folderKey);
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
} catch (error) { } catch (error) {

View File

@@ -4,8 +4,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors'; import { errorResponse, ValidationError } from '@/lib/errors';
import { minioClient } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
const createFolderSchema = z.object({ const createFolderSchema = z.object({
path: z.string().min(1).max(500), path: z.string().min(1).max(500),
@@ -29,9 +28,16 @@ export const POST = withAuth(
const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`; const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`;
// Create zero-byte marker object in MinIO // Zero-byte marker through the active storage backend. S3 stores it
await minioClient.putObject(env.MINIO_BUCKET, folderKey, Buffer.alloc(0), 0, { // as an empty object; the filesystem backend currently materializes
'Content-Type': 'application/x-directory', // it as an empty file (a future refactor should move folder
// bookkeeping to a DB-backed virtual-folder table — see
// docs/audit-comprehensive-2026-05-05.md HIGH §3 follow-up).
await (
await getStorageBackend()
).put(folderKey, Buffer.alloc(0), {
contentType: 'application/x-directory',
sizeBytes: 0,
}); });
return NextResponse.json({ data: { path: folderKey } }, { status: 201 }); return NextResponse.json({ data: { path: folderKey } }, { status: 201 });

View File

@@ -6,9 +6,8 @@ import { db } from '@/lib/db';
import { formSubmissions } from '@/lib/db/schema/documents'; import { formSubmissions } from '@/lib/db/schema/documents';
import { gdprExports } from '@/lib/db/schema/gdpr'; import { gdprExports } from '@/lib/db/schema/gdpr';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage'; import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { minioClient } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage';
import { QUEUE_CONFIGS } from '@/lib/queue'; import { QUEUE_CONFIGS } from '@/lib/queue';
/** AI usage rows older than this are deleted by the retention job. */ /** AI usage rows older than this are deleted by the retention job. */
@@ -87,7 +86,7 @@ export const maintenanceWorker = new Worker(
for (const row of expired) { for (const row of expired) {
try { try {
if (row.storageKey) { if (row.storageKey) {
await minioClient.removeObject(env.MINIO_BUCKET, row.storageKey); await (await getStorageBackend()).delete(row.storageKey);
} }
await db.delete(gdprExports).where(eq(gdprExports.id, row.id)); await db.delete(gdprExports).where(eq(gdprExports.id, row.id));
removed++; removed++;

View File

@@ -13,7 +13,8 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff'; import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors'; import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio'; import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate'; import { generatePdf } from '@/lib/pdf/generate';
@@ -525,13 +526,14 @@ export async function generateFromTemplate(
'pdf', 'pdf',
); );
await minioClient.putObject( {
env.MINIO_BUCKET, const buffer = Buffer.from(pdfBytes);
storagePath, const backend = await getStorageBackend();
Buffer.from(pdfBytes), await backend.put(storagePath, buffer, {
pdfBytes.byteLength, contentType: 'application/pdf',
{ 'Content-Type': 'application/pdf' }, sizeBytes: buffer.length,
); });
}
// Create file record // Create file record
const [fileRecord] = await db const [fileRecord] = await db
@@ -661,13 +663,14 @@ async function generateEoiFromSourcePdf(
'pdf', 'pdf',
); );
await minioClient.putObject( {
env.MINIO_BUCKET, const buffer = Buffer.from(pdfBytes);
storagePath, const backend = await getStorageBackend();
Buffer.from(pdfBytes), await backend.put(storagePath, buffer, {
pdfBytes.byteLength, contentType: 'application/pdf',
{ 'Content-Type': 'application/pdf' }, sizeBytes: buffer.length,
); });
}
const [fileRecord] = await db const [fileRecord] = await db
.insert(files) .insert(files)
@@ -809,11 +812,13 @@ async function generateAndSignViaInApp(
file: DbFile; file: DbFile;
}); });
// Fetch PDF bytes from MinIO to send to Documenso // Fetch PDF bytes from the active storage backend to send to Documenso.
const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath); const pdfStream = await (await getStorageBackend()).get(file.storagePath);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of pdfStream) { for await (const chunk of pdfStream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer)); if (Buffer.isBuffer(chunk)) chunks.push(chunk);
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
else chunks.push(Buffer.from(chunk as Uint8Array));
} }
const pdfBase64 = Buffer.concat(chunks).toString('base64'); const pdfBase64 = Buffer.concat(chunks).toString('base64');

View File

@@ -20,7 +20,8 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff'; import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors'; import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio'; import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { evaluateRule } from '@/lib/services/berth-rules-engine';
@@ -549,7 +550,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const fileRecord = await db.query.files.findFirst({ where: eq(files.id, doc.fileId) }); const fileRecord = await db.query.files.findFirst({ where: eq(files.id, doc.fileId) });
if (!fileRecord) throw new NotFoundError('File'); if (!fileRecord) throw new NotFoundError('File');
const fileStream = await minioClient.getObject(env.MINIO_BUCKET, fileRecord.storagePath); const fileStream = await (await getStorageBackend()).get(fileRecord.storagePath);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of fileStream) { for await (const chunk of fileStream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -659,8 +660,11 @@ export async function uploadSignedManually(
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf'); const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, { await (
'Content-Type': fileData.mimeType, await getStorageBackend()
).put(storagePath, fileData.buffer, {
contentType: fileData.mimeType,
sizeBytes: fileData.size,
}); });
const [fileRecord] = await db const [fileRecord] = await db
@@ -860,13 +864,12 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', doc.id, fileId, 'pdf'); const storagePath = buildStoragePath(port.slug, 'eoi-signed', doc.id, fileId, 'pdf');
await minioClient.putObject( await (
env.MINIO_BUCKET, await getStorageBackend()
storagePath, ).put(storagePath, signedPdfBuffer, {
signedPdfBuffer, contentType: 'application/pdf',
signedPdfBuffer.length, sizeBytes: signedPdfBuffer.length,
{ 'Content-Type': 'application/pdf' }, });
);
const [fileRecord] = await db const [fileRecord] = await db
.insert(files) .insert(files)

View File

@@ -114,8 +114,8 @@ export async function sendEmail(
where: eq(files.id, ref.fileId), where: eq(files.id, ref.fileId),
}); });
if (!file) throw new NotFoundError('File'); if (!file) throw new NotFoundError('File');
const { minioClient } = await import('@/lib/minio'); const { getStorageBackend } = await import('@/lib/storage');
const stream = await minioClient.getObject(file.storageBucket, file.storagePath); const stream = await (await getStorageBackend()).get(file.storagePath);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));

View File

@@ -7,7 +7,7 @@ import { berthMaintenanceLog } from '@/lib/db/schema/berths';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { minioClient, getPresignedUrl } from '@/lib/minio'; import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { buildListQuery } from '@/lib/db/query-builder'; import { buildListQuery } from '@/lib/db/query-builder';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { import {
@@ -50,8 +50,10 @@ export async function uploadFile(
const sanitizedOriginal = sanitizeFilename(file.originalName); const sanitizedOriginal = sanitizeFilename(file.originalName);
const sanitizedFilename = sanitizeFilename(data.filename); const sanitizedFilename = sanitizeFilename(data.filename);
await minioClient.putObject(env.MINIO_BUCKET, storagePath, file.buffer, file.size, { const backend = await getStorageBackend();
'Content-Type': file.mimeType, await backend.put(storagePath, file.buffer, {
contentType: file.mimeType,
sizeBytes: file.size,
}); });
const [record] = await db const [record] = await db
@@ -93,7 +95,7 @@ export async function uploadFile(
export async function getDownloadUrl(id: string, portId: string) { export async function getDownloadUrl(id: string, portId: string) {
const file = await getFileById(id, portId); const file = await getFileById(id, portId);
const url = await getPresignedUrl(file.storagePath); const url = await presignDownloadUrl(file.storagePath);
return { url, filename: file.filename }; return { url, filename: file.filename };
} }
@@ -104,7 +106,7 @@ export async function getPreviewUrl(id: string, portId: string) {
throw new ValidationError('This file type cannot be previewed'); throw new ValidationError('This file type cannot be previewed');
} }
const url = await getPresignedUrl(file.storagePath); const url = await presignDownloadUrl(file.storagePath);
return { url, mimeType: file.mimeType }; return { url, mimeType: file.mimeType };
} }
@@ -183,8 +185,10 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
throw new ConflictError('File cannot be deleted because it is referenced by other records'); throw new ConflictError('File cannot be deleted because it is referenced by other records');
} }
// Delete from MinIO first, then DB // Delete the blob first, then DB. The storage backend's delete is
await minioClient.removeObject(env.MINIO_BUCKET, existing.storagePath); // idempotent, so a partial replay (worker crashed mid-delete) does not
// throw on the missing-object retry.
await (await getStorageBackend()).delete(existing.storagePath);
await db.delete(files).where(and(eq(files.id, id), eq(files.portId, portId))); await db.delete(files).where(and(eq(files.id, id), eq(files.portId, portId)));

View File

@@ -18,10 +18,9 @@ import { db } from '@/lib/db';
import { gdprExports, type GdprExport } from '@/lib/db/schema/gdpr'; import { gdprExports, type GdprExport } from '@/lib/db/schema/gdpr';
import { clients, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientContacts } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { env } from '@/lib/env';
import { NotFoundError, ValidationError } from '@/lib/errors'; import { NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { minioClient, getPresignedUrl } from '@/lib/minio'; import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { getQueue } from '@/lib/queue'; import { getQueue } from '@/lib/queue';
import { createAuditLog } from '@/lib/audit'; import { createAuditLog } from '@/lib/audit';
import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder'; import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder';
@@ -163,9 +162,11 @@ export async function processGdprExportJob(input: ProcessJobInput): Promise<void
const portSlug = port?.slug ?? 'unknown'; const portSlug = port?.slug ?? 'unknown';
const storageKey = `${portSlug}/gdpr-exports/${input.clientId}/${input.exportId}.zip`; const storageKey = `${portSlug}/gdpr-exports/${input.clientId}/${input.exportId}.zip`;
await minioClient.putObject(env.MINIO_BUCKET, storageKey, buffer, buffer.length, { const backend = await getStorageBackend();
'Content-Type': 'application/zip', await backend.put(storageKey, buffer, {
'Content-Disposition': `attachment; filename="gdpr-export-${input.clientId}.zip"`, contentType: 'application/zip',
sizeBytes: buffer.length,
contentDisposition: `attachment; filename="gdpr-export-${input.clientId}.zip"`,
}); });
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
@@ -217,7 +218,7 @@ async function emailExport(input: ProcessJobInput, storageKey: string): Promise<
return; return;
} }
const url = await getPresignedUrl(storageKey, PRESIGN_EXPIRY_SECONDS); const url = await presignDownloadUrl(storageKey, PRESIGN_EXPIRY_SECONDS);
const client = await db.query.clients.findFirst({ where: eq(clients.id, input.clientId) }); const client = await db.query.clients.findFirst({ where: eq(clients.id, input.clientId) });
const name = client?.fullName ?? 'there'; const name = client?.fullName ?? 'there';
const expiry = new Date(Date.now() + PRESIGN_EXPIRY_SECONDS * 1000).toUTCString(); const expiry = new Date(Date.now() + PRESIGN_EXPIRY_SECONDS * 1000).toUTCString();
@@ -275,5 +276,5 @@ export async function getExportDownloadUrl(exportId: string, portId: string): Pr
if (!row.storageKey || (row.status !== 'ready' && row.status !== 'sent')) { if (!row.storageKey || (row.status !== 'ready' && row.status !== 'sent')) {
throw new ValidationError('Export is not ready to download'); throw new ValidationError('Export is not ready to download');
} }
return getPresignedUrl(row.storageKey, PRESIGN_EXPIRY_SECONDS); return presignDownloadUrl(row.storageKey, PRESIGN_EXPIRY_SECONDS);
} }

View File

@@ -19,7 +19,8 @@ import { emitToRoom } from '@/lib/socket/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate'; import { generatePdf } from '@/lib/pdf/generate';
import { invoiceTemplate, buildInvoiceInputs } from '@/lib/pdf/templates/invoice-template'; import { invoiceTemplate, buildInvoiceInputs } from '@/lib/pdf/templates/invoice-template';
import { minioClient, buildStoragePath } from '@/lib/minio'; import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { getQueue } from '@/lib/queue'; import { getQueue } from '@/lib/queue';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import type { import type {
@@ -595,13 +596,12 @@ export async function generateInvoicePdf(id: string, portId: string, meta: Audit
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port?.slug ?? portId, 'invoices', id, fileId, 'pdf'); const storagePath = buildStoragePath(port?.slug ?? portId, 'invoices', id, fileId, 'pdf');
await minioClient.putObject( const buffer = Buffer.from(pdfBytes);
env.MINIO_BUCKET, const backend = await getStorageBackend();
storagePath, await backend.put(storagePath, buffer, {
Buffer.from(pdfBytes), contentType: 'application/pdf',
pdfBytes.length, sizeBytes: buffer.length,
{ 'Content-Type': 'application/pdf' }, });
);
const [fileRecord] = await db const [fileRecord] = await db
.insert(files) .insert(files)

View File

@@ -11,7 +11,7 @@ import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { getPresignedUrl } from '@/lib/minio'; import { presignDownloadUrl } from '@/lib/storage';
import { getCountryName } from '@/lib/i18n/countries'; import { getCountryName } from '@/lib/i18n/countries';
// ─── Dashboard ──────────────────────────────────────────────────────────────── // ─── Dashboard ────────────────────────────────────────────────────────────────
@@ -301,7 +301,7 @@ export async function getDocumentDownloadUrl(
if (!file) return null; if (!file) return null;
return getPresignedUrl(file.storagePath); return presignDownloadUrl(file.storagePath);
} }
// ─── Yachts (direct + via company) ──────────────────────────────────────────── // ─── Yachts (direct + via company) ────────────────────────────────────────────

View File

@@ -6,7 +6,8 @@ import { notifications } from '@/lib/db/schema/operations';
import { files } from '@/lib/db/schema/documents'; import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { generatePdf } from '@/lib/pdf/generate'; import { generatePdf } from '@/lib/pdf/generate';
import { minioClient, getPresignedUrl, buildStoragePath } from '@/lib/minio/index'; import { buildStoragePath } from '@/lib/minio/index';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { getQueue } from '@/lib/queue'; import { getQueue } from '@/lib/queue';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
@@ -67,11 +68,7 @@ type ReportType = keyof typeof REPORT_TYPE_MAP;
// ─── requestReport ──────────────────────────────────────────────────────────── // ─── requestReport ────────────────────────────────────────────────────────────
export async function requestReport( export async function requestReport(portId: string, userId: string, data: RequestReportInput) {
portId: string,
userId: string,
data: RequestReportInput,
) {
const [report] = await db const [report] = await db
.insert(generatedReports) .insert(generatedReports)
.values({ .values({
@@ -163,7 +160,7 @@ export async function getDownloadUrl(reportId: string, portId: string) {
throw new NotFoundError('File'); throw new NotFoundError('File');
} }
const url = await getPresignedUrl(file.storagePath); const url = await presignDownloadUrl(file.storagePath);
return { url }; return { url };
} }
@@ -208,7 +205,9 @@ export async function generateReport(reportJobId: string): Promise<void> {
const portSlug = port?.slug ?? 'port'; const portSlug = port?.slug ?? 'port';
// 6. Build inputs (pass portName) // 6. Build inputs (pass portName)
const inputs = (config.buildInputs as (data: unknown, portName: string) => Record<string, string>[])(data, portName); const inputs = (
config.buildInputs as (data: unknown, portName: string) => Record<string, string>[]
)(data, portName);
// 7. Generate PDF // 7. Generate PDF
const pdfBytes = await generatePdf(config.template, inputs); const pdfBytes = await generatePdf(config.template, inputs);
@@ -217,15 +216,13 @@ export async function generateReport(reportJobId: string): Promise<void> {
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf'); const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf');
// 9. Upload PDF to MinIO // 9. Upload PDF via the active storage backend (filesystem or s3)
const buffer = Buffer.from(pdfBytes); const buffer = Buffer.from(pdfBytes);
await minioClient.putObject( const backend = await getStorageBackend();
env.MINIO_BUCKET, await backend.put(storagePath, buffer, {
storagePath, contentType: 'application/pdf',
buffer, sizeBytes: buffer.length,
buffer.length, });
{ 'Content-Type': 'application/pdf', 'report-type': reportType },
);
// 10. Insert into files table // 10. Insert into files table
const [fileRecord] = await db const [fileRecord] = await db

View File

@@ -194,6 +194,24 @@ async function buildBackend(cfg: StorageConfigSnapshot): Promise<StorageBackend>
}); });
} }
// ─── url helpers ────────────────────────────────────────────────────────────
/**
* Convenience wrapper that returns just the presigned download URL — the most
* common need at call sites that don't track expiry. Mirrors the legacy
* `getPresignedUrl(key)` helper in `@/lib/minio` but routes through the
* active backend so filesystem-mode deployments work too.
*/
export async function presignDownloadUrl(
key: string,
expirySeconds = 900,
filename?: string,
): Promise<string> {
const backend = await getStorageBackend();
const { url } = await backend.presignDownload(key, { expirySeconds, filename });
return url;
}
// ─── re-exports ───────────────────────────────────────────────────────────── // ─── re-exports ─────────────────────────────────────────────────────────────
export { S3Backend } from './s3'; export { S3Backend } from './s3';