fix(documents): idempotency, perf, contract pipeline, observability

- A1: idempotency gate in handleDocumentCompleted (prevents duplicate files on Documenso retry)
- A3: LEFT JOIN port_id move to outer WHERE (uses idx_docs_signed_file_id)
- G-C5: contract_sent / contract_signed auto-advance triggers in sendDocument + handleDocumentCompleted
- 0-byte signed PDF guard before storage.put
- portId in outer catch + poll worker
- Sanitize storagePath/storageBucket in aggregated files API
- Audit log for handleDocumentCompleted file insert
- Replace em-dashes in aggregated group labels with colons
- G-I6: delete orphaned hub-counts route + getHubTabCounts service fn

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:56:46 +02:00
parent c0e5af8b92
commit c761b4b911
6 changed files with 138 additions and 123 deletions

View File

@@ -280,10 +280,23 @@ export async function getFileById(id: string, portId: string) {
// ─── Aggregated Projection ────────────────────────────────────────────────────
/**
* Row shape returned by the aggregated projection. Note this intentionally
* omits `storagePath` and `storageBucket` — those are internal storage
* implementation details and must not leak out of the API to rep clients.
* Callers that need to download a file must use the documents/file
* download endpoint, which presigns from the bucket using the id, not the
* raw path.
*/
export type AggregatedFileRow = Omit<
typeof files.$inferSelect,
'storagePath' | 'storageBucket'
> & { signedFromDocumentId: string | null };
export interface AggregatedFileGroup {
label: string;
source: 'direct' | 'client' | 'company' | 'yacht';
files: Array<typeof files.$inferSelect & { signedFromDocumentId: string | null }>;
files: AggregatedFileRow[];
total: number;
}
@@ -332,7 +345,7 @@ export async function listFilesAggregatedByEntity(
const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM COMPANY ${name.toUpperCase()}`,
label: `FROM COMPANY: ${name.toUpperCase()}`,
source: 'company',
files: g.rows,
total: g.total,
@@ -343,7 +356,7 @@ export async function listFilesAggregatedByEntity(
const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM YACHT ${name.toUpperCase()}`,
label: `FROM YACHT: ${name.toUpperCase()}`,
source: 'yacht',
files: g.rows,
total: g.total,
@@ -354,7 +367,7 @@ export async function listFilesAggregatedByEntity(
const g = await fetchGroupRows(portId, eq(files.clientId, id), GROUP_LIMIT);
if (g.rows.length === 0) continue;
groups.push({
label: `FROM CLIENT ${name.toUpperCase()}`,
label: `FROM CLIENT: ${name.toUpperCase()}`,
source: 'client',
files: g.rows,
total: g.total,
@@ -515,9 +528,14 @@ async function fetchGroupRows(
predicate: ReturnType<typeof eq>,
limit: number,
): Promise<{
rows: Array<typeof files.$inferSelect & { signedFromDocumentId: string | null }>;
rows: AggregatedFileRow[];
total: number;
}> {
// A3: keep the LEFT JOIN's ON clause minimal so the planner can use the
// point-lookup index `idx_docs_signed_file_id` on the join, and apply the
// port_id residual in the WHERE (with the `OR d.id IS NULL` clause so the
// LEFT-JOIN semantics still preserve unjoined file rows). With port_id in
// the ON we used to fall back to `idx_docs_port` which is a wide-range scan.
const rows = await db
.select({
id: files.id,
@@ -530,19 +548,25 @@ async function fetchGroupRows(
originalName: files.originalName,
mimeType: files.mimeType,
sizeBytes: files.sizeBytes,
storagePath: files.storagePath,
storageBucket: files.storageBucket,
// storagePath + storageBucket intentionally omitted — see AggregatedFileRow doc.
category: files.category,
uploadedBy: files.uploadedBy,
createdAt: files.createdAt,
// Reverse-link: if any document row has this file as its signed_file_id,
// surface that document's id. LEFT JOIN preserves files with no workflow link.
// Defense-in-depth: portId filter on both the join condition and the outer where.
// surface that document's id.
signedFromDocumentId: documents.id,
})
.from(files)
.leftJoin(documents, and(eq(documents.signedFileId, files.id), eq(documents.portId, portId)))
.where(and(eq(files.portId, portId), predicate))
.leftJoin(documents, eq(documents.signedFileId, files.id))
.where(
and(
eq(files.portId, portId),
predicate,
// Defense-in-depth: keep the cross-port-leakage guard on the joined
// doc row but allow unjoined files (id IS NULL).
or(eq(documents.portId, portId), isNull(documents.id)),
),
)
.orderBy(desc(files.createdAt))
.limit(limit);