fix(live-reporting): bulk download poller no longer hangs after completion

The status poll only declared "ready" when it could parse presigned_url out
of sub_task.outputs, so a finished job whose link used a different key (or
whose completion was only reflected in status) sat on "preparing" forever.

- Derive terminal state from completion/failure *status* (task or sub_task,
  incl. jobCompleted), not just the parsed link.
- Tolerate link-key variants (presigned_url / presignedUrl / download_url /
  url; package_s3_key / s3_key).
- When completed but the link can't be surfaced in-app, show a "ready — check
  your email" card instead of hanging.
- Ease the poll cadence to 8s (assembly takes minutes; email is the real
  channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-08 16:34:00 +00:00
parent 7a7f7e157e
commit 098a5ccffc
4 changed files with 78 additions and 12 deletions

View file

@ -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 (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-200 bg-emerald-50/70 px-4 py-3"
>
<CheckCircle2 className="h-5 w-5 flex-none text-emerald-600" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">Your download is ready</p>
<p className="text-[13px] text-gray-600">
Weve emailed you the link its valid for about 60 minutes.
</p>
</div>
<button
onClick={onDismiss}
className="ml-auto inline-flex items-center gap-1.5 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Dismiss
</button>
</div>
);
}
if (state === "ready" && status?.delivery) {
const { presignedUrl, included, skipped } = status.delivery;
return (

View file

@ -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",

View file

@ -156,11 +156,19 @@ export function parseDelivery(outputs: string | null): BulkDownloadDelivery | nu
if (!outputs) return null;
try {
const raw = JSON.parse(outputs) as Record<string, unknown>;
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;
}

View file

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