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

@@ -34,7 +34,11 @@ import {
voidDocument as documensoVoid,
} from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { listTree, collectDescendantIds } from '@/lib/services/document-folders.service';
import {
listTree,
collectDescendantIds,
type FolderNode,
} from '@/lib/services/document-folders.service';
import type {
CreateDocumentInput,
UpdateDocumentInput,
@@ -217,7 +221,7 @@ export async function listDocuments(
? documents.documentType
: documents.createdAt;
return buildListQuery({
const result = await buildListQuery<typeof documents.$inferSelect>({
table: documents,
portIdColumn: documents.portId,
portId,
@@ -230,6 +234,85 @@ export async function listDocuments(
page,
pageSize: limit,
});
const hydrated = await hydrateDocumentsWithDownloadUrl(portId, result.data);
return { ...result, data: hydrated };
}
// ─── Download URL helpers ─────────────────────────────────────────────────────
/**
* Resolve the rep-facing download URL for a document. The URL embeds the
* folder path + filename for browser-tab / shared-link readability, but the
* route handler keys lookup off the doc id and validates the slug for truth
* — a hand-edited URL with the wrong path 404s instead of silently serving
* the wrong file.
*
* Pass the resolved folder tree once per request and call this for each doc
* so we don't refetch the tree per row. Returns `null` when the document has
* no attached file (signature-only docs pre-completion); UI consumers branch
* on that to decide whether to render the download affordance.
*/
export function buildDocumentDownloadUrl(
doc: { id: string; folderId: string | null; filename: string | null },
folderTree: readonly FolderNode[],
): string | null {
if (!doc.filename) return null;
const segments: string[] = [];
if (doc.folderId) {
const path = findFolderPath(folderTree, doc.folderId);
for (const node of path) segments.push(encodeURIComponent(node.name));
}
segments.push(encodeURIComponent(doc.filename));
return `/api/v1/documents/${doc.id}/download/${segments.join('/')}`;
}
/**
* Walk the folder tree to materialize the ancestor chain that ends at
* `id`. Returns roots-first; empty when the id is missing (orphan
* `folder_id` pointer — see listTree's intentional silent drop).
*/
export function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] {
for (const node of tree) {
if (node.id === id) return [node];
const inChild = findFolderPath(node.children, id);
if (inChild.length > 0) return [node, ...inChild];
}
return [];
}
type DocumentRow = typeof documents.$inferSelect;
type DocumentRowWithDownload = DocumentRow & { downloadUrl: string | null };
async function hydrateDocumentsWithDownloadUrl(
portId: string,
rows: DocumentRow[],
): Promise<DocumentRowWithDownload[]> {
if (rows.length === 0) return [];
const fileIds = Array.from(
new Set(rows.map((r) => r.fileId).filter((v): v is string => v !== null)),
);
const filenameById = new Map<string, string | null>();
if (fileIds.length > 0) {
const fileRows = await db
.select({ id: files.id, filename: files.filename })
.from(files)
.where(and(inArray(files.id, fileIds), eq(files.portId, portId)));
for (const f of fileRows) filenameById.set(f.id, f.filename);
}
const tree = await listTree(portId);
return rows.map((row) => {
const filename = row.fileId ? (filenameById.get(row.fileId) ?? null) : null;
return {
...row,
downloadUrl: buildDocumentDownloadUrl(
{ id: row.id, folderId: row.folderId, filename },
tree,
),
};
});
}
// ─── Deal docs for a berth ────────────────────────────────────────────────────
@@ -344,7 +427,23 @@ export async function getDocumentById(id: string, portId: string) {
});
if (!doc) throw new NotFoundError('Document');
return doc;
let filename: string | null = null;
if (doc.fileId) {
const fileRow = await db
.select({ filename: files.filename })
.from(files)
.where(and(eq(files.id, doc.fileId), eq(files.portId, portId)))
.limit(1)
.then((r) => r[0]);
filename = fileRow?.filename ?? null;
}
const tree = await listTree(portId);
const downloadUrl = buildDocumentDownloadUrl(
{ id: doc.id, folderId: doc.folderId, filename },
tree,
);
return { ...doc, downloadUrl };
}
/**