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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user