feat(documents): path-style download URLs for rep-facing readability

Storage paths stay UUID-flat per the established CRM pattern (every
other content type — brochures, berth PDFs, invoices, reports,
templates, expense receipts — uses the same shape). The new
catch-all /api/v1/documents/[id]/download/[...slug] route serves
files keyed on doc id but rebuilds the slug from current state and
404s on mismatch — a hand-edited or stale link can't render the
wrong filename or fold a wrong-folder path into a forwarded URL.

URLs in shared links / browser tabs read like
'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs.
listDocuments + getDocumentById now hydrate a `downloadUrl` field
per row (null when no file is attached yet) so UI consumers don't
reconstruct paths. Filename is batch-fetched via files-table join
to keep the query builder shape unchanged.

Tests: 5 integration cases — happy-path stream, wrong-folder slug,
wrong-filename slug, orphaned doc (no fileId), cross-port (tenancy
isolation). Storage backend swapped to a real FilesystemBackend in
a tempdir so the byte-streaming path is exercised end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:50:16 +02:00
parent cf8bbf3018
commit e790ff708b
4 changed files with 418 additions and 3 deletions

View File

@@ -0,0 +1,98 @@
/**
* Route handler for `GET /api/v1/documents/[id]/download/[...slug]`.
*
* Lives in handlers.ts (not route.ts) so integration tests can call it
* directly, bypassing auth/permission middleware (per CLAUDE.md "Route
* handler exports" convention).
*
* Lookup is keyed off the doc id; the slug embeds the current folder path +
* filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even
* though the underlying storage key is a UUID. The slug is rebuilt from
* current state and compared with the supplied path — a stale or
* hand-edited URL 404s rather than silently serving the wrong file.
*/
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { type RouteHandler } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { documents, files } from '@/lib/db/schema/documents';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { getStorageBackend } from '@/lib/storage';
import { listTree, type FolderNode } from '@/lib/services/document-folders.service';
import { findFolderPath } from '@/lib/services/documents.service';
type DownloadParams = { id: string; slug: string[] };
export const downloadHandler: RouteHandler<DownloadParams> = async (_req, ctx, params) => {
try {
const docId = params.id;
if (!docId) throw new NotFoundError('Document');
const doc = await db
.select({
id: documents.id,
folderId: documents.folderId,
fileId: documents.fileId,
fileStoragePath: files.storagePath,
fileMimeType: files.mimeType,
fileFilename: files.filename,
})
.from(documents)
.leftJoin(files, eq(files.id, documents.fileId))
.where(and(eq(documents.id, docId), eq(documents.portId, ctx.portId)))
.limit(1)
.then((r) => r[0]);
if (!doc) throw new NotFoundError('Document');
if (!doc.fileStoragePath || !doc.fileFilename) {
throw new NotFoundError('Document file');
}
const tree = await listTree(ctx.portId);
const expected = buildExpectedSlug(doc.folderId, doc.fileFilename, tree);
const supplied = (params.slug ?? []).map(decodeURIComponent).join('/');
if (supplied !== expected) throw new NotFoundError('Document at this path');
const backend = await getStorageBackend();
const nodeStream = await backend.get(doc.fileStoragePath);
const webStream = Readable.toWeb(nodeStream as Readable) as ReadableStream;
return new NextResponse(webStream, {
status: 200,
headers: {
'content-type': doc.fileMimeType ?? 'application/octet-stream',
'content-disposition': `inline; filename="${sanitizeFilenameForHeader(doc.fileFilename)}"`,
},
});
} catch (error) {
return errorResponse(error);
}
};
function buildExpectedSlug(
folderId: string | null,
filename: string,
tree: FolderNode[],
): string {
const segments: string[] = [];
if (folderId) {
const path = findFolderPath(tree, folderId);
for (const node of path) segments.push(node.name);
}
segments.push(filename);
return segments.join('/');
}
/**
* Strip control + quote characters from a filename before placing it in the
* Content-Disposition header value. The legacy `inline; filename="..."` form
* is what the rest of the CRM emits today; this is the same minimal
* sanitization the other download endpoints rely on.
*/
function sanitizeFilenameForHeader(filename: string): string {
return filename.replace(/["\\\r\n]/g, '_');
}

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { downloadHandler } from './handlers';
export const GET = withAuth(withPermission('documents', 'view', downloadHandler));