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 };
}