diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx index 5b8bf746..d8b174e8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx @@ -242,6 +242,31 @@ function DeliveryCard({ const [showSkipped, setShowSkipped] = useState(false); const state = status?.state ?? "preparing"; + // Completed, but the link couldn't be surfaced in-app (unparseable outputs). + // The worker still emails it — send the user there rather than hang. + if (state === "ready" && !status?.delivery) { + return ( +
+ +
+

Your download is ready

+

+ We’ve emailed you the link — it’s valid for about 60 minutes. +

+
+ +
+ ); + } + if (state === "ready" && status?.delivery) { const { presignedUrl, included, skipped } = status.delivery; return ( diff --git a/src/lib/bulkDocumentDownload/model.test.ts b/src/lib/bulkDocumentDownload/model.test.ts index d0932047..c2d9d007 100644 --- a/src/lib/bulkDocumentDownload/model.test.ts +++ b/src/lib/bulkDocumentDownload/model.test.ts @@ -157,6 +157,19 @@ describe("parseDelivery", () => { }); }); + it("tolerates link-key naming variants", () => { + expect(parseDelivery(JSON.stringify({ presignedUrl: "https://a" }))?.presignedUrl).toBe( + "https://a", + ); + expect(parseDelivery(JSON.stringify({ download_url: "https://b" }))?.presignedUrl).toBe( + "https://b", + ); + expect(parseDelivery(JSON.stringify({ url: "https://c", s3_key: "k" }))).toMatchObject({ + presignedUrl: "https://c", + packageS3Key: "k", + }); + }); + it("returns null for in-flight or malformed outputs", () => { expect(parseDelivery(null)).toBeNull(); expect(parseDelivery("")).toBeNull(); @@ -167,8 +180,8 @@ describe("parseDelivery", () => { describe("bulkDownloadRefetchInterval", () => { it("polls while preparing, stops once resolved", () => { - expect(bulkDownloadRefetchInterval(undefined)).toBe(4_000); - expect(bulkDownloadRefetchInterval({ state: "preparing", delivery: null })).toBe(4_000); + expect(bulkDownloadRefetchInterval(undefined)).toBe(8_000); + expect(bulkDownloadRefetchInterval({ state: "preparing", delivery: null })).toBe(8_000); expect( bulkDownloadRefetchInterval({ state: "ready", diff --git a/src/lib/bulkDocumentDownload/model.ts b/src/lib/bulkDocumentDownload/model.ts index 6c9b8dca..3eee8c60 100644 --- a/src/lib/bulkDocumentDownload/model.ts +++ b/src/lib/bulkDocumentDownload/model.ts @@ -156,11 +156,19 @@ export function parseDelivery(outputs: string | null): BulkDownloadDelivery | nu if (!outputs) return null; try { const raw = JSON.parse(outputs) as Record; - if (typeof raw.presigned_url !== "string" || !raw.presigned_url) return null; + // Tolerate link-key naming variants so a completed job surfaces its link. + const url = [raw.presigned_url, raw.presignedUrl, raw.download_url, raw.url].find( + (v): v is string => typeof v === "string" && v.length > 0, + ); + if (!url) return null; return { - presignedUrl: raw.presigned_url, + presignedUrl: url, packageS3Key: - typeof raw.package_s3_key === "string" ? raw.package_s3_key : null, + typeof raw.package_s3_key === "string" + ? raw.package_s3_key + : typeof raw.s3_key === "string" + ? raw.s3_key + : null, included: typeof raw.included === "number" ? raw.included : 0, skipped: Array.isArray(raw.skipped) ? (raw.skipped as BulkDownloadSkipped[]) @@ -179,6 +187,8 @@ export function parseDelivery(outputs: string | null): BulkDownloadDelivery | nu export function bulkDownloadRefetchInterval( data: BulkDownloadStatus | undefined, ): number | false { - if (!data) return 4_000; - return data.state === "preparing" ? 4_000 : false; + // Package assembly takes minutes, so a gentle cadence — email is the real + // delivery channel; this poll is just an in-session convenience. + if (!data) return 8_000; + return data.state === "preparing" ? 8_000 : false; } diff --git a/src/lib/bulkDocumentDownload/server.ts b/src/lib/bulkDocumentDownload/server.ts index 38d4f74c..88292f70 100644 --- a/src/lib/bulkDocumentDownload/server.ts +++ b/src/lib/bulkDocumentDownload/server.ts @@ -115,11 +115,21 @@ export async function triggerBulkDocumentDownload(args: { return { kind: "ok", taskId: task.id }; } +function isComplete(status: string | null): boolean { + return status + ? ["completed", "complete", "success", "succeeded", "done"].includes( + status.toLowerCase(), + ) + : false; +} + /** - * Status of a triggered download for in-app polling: reads the task's sub_task - * outputs. The worker writes the presigned URL there on success; a selection - * that yields zero documents fails the sub_task (no empty ZIP). Portfolio-scoped - * so a task id can't be read against another portfolio. + * Status of a triggered download for in-app polling. The worker writes the + * presigned URL to the sub_task's `outputs` on success; a selection that yields + * zero documents fails the sub_task (no empty ZIP). Terminal state is taken from + * completion/failure *status* (task or sub_task) as well as the parsed outputs — + * so a finished job never hangs on "preparing" just because the link couldn't be + * parsed. Portfolio-scoped so a task id can't be read against another portfolio. */ export async function getBulkDownloadStatus( portfolioId: string, @@ -128,7 +138,9 @@ export async function getBulkDownloadStatus( const rows = await db .select({ taskStatus: tasks.status, + taskDone: tasks.jobCompleted, subStatus: subTasks.status, + subDone: subTasks.jobCompleted, outputs: subTasks.outputs, }) .from(tasks) @@ -147,7 +159,13 @@ export async function getBulkDownloadStatus( const delivery = rows.map((r) => parseDelivery(r.outputs)).find(Boolean) ?? null; const failed = isFailed(rows[0].taskStatus) || rows.some((r) => isFailed(r.subStatus)); + // A job whose sub_task (or task) reports completion is terminal even if the + // outputs couldn't be parsed into a link — the link is still emailed. + const complete = + isComplete(rows[0].taskStatus) || + !!rows[0].taskDone || + rows.some((r) => isComplete(r.subStatus) || !!r.subDone); - const state = delivery ? "ready" : failed ? "failed" : "preparing"; + const state = delivery || complete ? "ready" : failed ? "failed" : "preparing"; return { state, delivery }; }