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