mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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:
parent
7a7f7e157e
commit
098a5ccffc
4 changed files with 78 additions and 12 deletions
|
|
@ -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">
|
||||
We’ve emailed you the link — it’s 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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue