From 34dd3fe7ae6d1f956f15dd03f5158acbcfc51cca Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Wed, 8 Jul 2026 14:11:42 +0000
Subject: [PATCH 1/3] feat(live-reporting): bulk document download from the
Documents tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add bulk document download as a row-selection flow on the Live Reporting
Documents tab: a "Bulk download" button flips the existing DocumentTable
into selection mode (per-row tickers + select-all across all filtered
pages); "Download selected" zips those properties' documents and emails a
link (shown in-app while the page stays open). Scoped by the tab's
existing Project/group selector.
Two-step trigger (ADR-0008 convention): insert an app-owned tasks row
(task_source "app:bulk_document_download", selection config JSON in
tasks.inputs, source_id = portfolio) then dispatch only { task_id } to
POST /v1/documents/bulk-download. Documents are matched by
landlord_property_id, so the ticked ids ARE the selection — a property
with no landlord id has no documents and is un-tickable.
Resilience: the client now parses responses defensively (a platform 504's
HTML body no longer surfaces as "Unexpected token 'A'…"), and the POST
route sets maxDuration=60 so the synchronous backend resolution has
headroom before the browser gets a 504.
Docs: CONTEXT.md gains Project + Bulk document download; ADR-0011 records
why the marker lives in task_source (worker re-reads the task) vs service.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CONTEXT.md | 10 +
...11-app-owned-task-marker-in-task-source.md | 46 +++
.../bulk-document-download/[taskId]/route.ts | 31 ++
.../bulk-document-download/route.ts | 82 +++++
.../live/DocumentBulkDownload.tsx | 334 ++++++++++++++++++
.../your-projects/live/DocumentTable.tsx | 70 +++-
.../your-projects/live/LiveTracker.tsx | Bin 22482 -> 22596 bytes
src/lib/bulkDocumentDownload/model.test.ts | 180 ++++++++++
src/lib/bulkDocumentDownload/model.ts | 184 ++++++++++
src/lib/bulkDocumentDownload/server.ts | 153 ++++++++
10 files changed, 1088 insertions(+), 2 deletions(-)
create mode 100644 docs/adr/0011-app-owned-task-marker-in-task-source.md
create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts
create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts
create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx
create mode 100644 src/lib/bulkDocumentDownload/model.test.ts
create mode 100644 src/lib/bulkDocumentDownload/model.ts
create mode 100644 src/lib/bulkDocumentDownload/server.ts
diff --git a/CONTEXT.md b/CONTEXT.md
index 33bf900a..de164f7b 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -112,6 +112,16 @@ _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution r
The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
+### Live projects
+
+**Project**:
+A delivery programme within a Portfolio — a batch of Properties taken through retrofit together — identified by its HubSpot **project code** (`hubspot_deal_data.project_code`; a stable **project id** will replace the code once the HubSpot ops board is settled, so the code is the *current* identifier, not the essence). A Property whose deal carries no project code sits outside every Project (the "Unknown Project" grouping) and cannot be reached by selecting Projects.
+_Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-deal field)
+
+**Bulk document download**:
+An app-triggered, asynchronous assembly of a single ZIP of the documents held against a chosen set of Properties — picked as a row selection in the Documents tab, scoped by the tab's current Project/group — delivered as a time-limited link emailed to the requester (and, within the session, retrievable in-app). A selection that matches no documents produces no ZIP: it fails rather than emailing an empty archive. Properties are matched to their documents by **landlord property id**. See [ADR-0011](./docs/adr/0011-app-owned-task-marker-in-task-source.md).
+_Avoid_: bulk export, document export, zip download
+
## Lifecycle
A **BulkUpload** moves through these statuses:
diff --git a/docs/adr/0011-app-owned-task-marker-in-task-source.md b/docs/adr/0011-app-owned-task-marker-in-task-source.md
new file mode 100644
index 00000000..01534378
--- /dev/null
+++ b/docs/adr/0011-app-owned-task-marker-in-task-source.md
@@ -0,0 +1,46 @@
+# 11. App-owned, backend-executed tasks carry their marker in `task_source`
+
+Date: 2026-07-08
+
+## Status
+
+Accepted
+
+## Context
+
+Bulk document download follows the app-created-task convention (ADR-0008): Next.js
+inserts a `tasks` row whose `inputs` column carries the selection config, then hands
+the backend only `{ task_id }`. Unlike a Modelling run — where the distributor
+receives the whole config in the dispatch payload and never looks at the task again —
+the document worker gets *only* the id, re-reads the `tasks` row, and resolves the
+selection from `inputs`. It therefore needs a durable, in-row way to recognise "this
+is a bulk-document-download task I'm allowed to act on" before it assembles anything.
+
+Modelling runs put that marker in `service` (`modelling_run`) and leave `task_source`
+human-readable (`"Modelling run – 2 scenarios"`). Reusing `service` here would work,
+but it invites a future engineer to "unify" the two conventions and move the marker —
+silently breaking the worker's guard.
+
+## Decision
+
+- **The marker is `task_source = "app:bulk_document_download"`** — a single, stable
+ string the worker guards on. The `app:` prefix denotes an app-created task (as
+ opposed to a backend-created one). It is engineer-facing only (Settings → Logs), so
+ a machine-shaped string is acceptable; no friendly-label mapping is added.
+- **`service` is left null.** For a task whose marker already lives in `task_source`,
+ a second marker in `service` is redundant and can drift. In-app status polling
+ scopes by `id = task_id AND source_id = AND task_source =
+ "app:bulk_document_download"`.
+- **Portfolio attachment is unchanged and uniform:** `source = "portfolio_id"`,
+ `source_id = `. This is what the Portfolio Logs page filters on, so
+ the run shows up there for free — the same as every other app-owned task.
+
+## Consequences
+
+- Do **not** move this marker into `service` to match Modelling runs. The asymmetry is
+ deliberate: the marker lives where the executor reads it — `service` for a
+ payload-fed distributor, `task_source` for a task-re-reading worker.
+- A large selection never travels in an HTTP body: it lives in `inputs`, and the only
+ thing dispatched is `task_id` (ADR-0008 lineage).
+- Future app-owned, backend-executed tasks should follow this shape:
+ `task_source = "app:"`, config in `inputs`, `source`/`source_id` for scoping.
diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts
new file mode 100644
index 00000000..fef05d07
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { getBulkDownloadStatus } from "@/lib/bulkDocumentDownload/server";
+
+// GET — status of a triggered bulk download, for in-app polling. Reads the
+// worker's sub_task.outputs (presigned URL, included count, skipped list).
+export async function GET(
+ _request: NextRequest,
+ props: { params: Promise<{ portfolioId: string; taskId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId, taskId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
+ }
+
+ try {
+ const status = await getBulkDownloadStatus(portfolioId, taskId);
+ if (!status) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json(status);
+ } catch (err) {
+ console.error("GET /bulk-document-download/[taskId] error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts
new file mode 100644
index 00000000..564206df
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { z } from "zod";
+import { triggerBulkDocumentDownload } from "@/lib/bulkDocumentDownload/server";
+
+// The document service resolves the selection and creates its sub_task
+// synchronously before returning 202, which can outrun the default function
+// budget on large projects. Give the round-trip headroom so the browser gets
+// our JSON (202 / mapped error) rather than a platform 504.
+export const maxDuration = 60;
+
+const scopeSchema = z.union([
+ z.object({ kind: z.literal("project"), projectCode: z.string().min(1) }),
+ z.object({
+ kind: z.literal("all-projects"),
+ projectCodes: z.array(z.string().min(1)),
+ }),
+]);
+
+const bodySchema = z.object({
+ scope: scopeSchema,
+ includeAll: z.boolean(),
+ landlordPropertyIds: z.array(z.string().min(1)).default([]),
+});
+
+function sessionToken(request: NextRequest): string | undefined {
+ return (
+ request.cookies.get("__Secure-next-auth.session-token")?.value ??
+ request.cookies.get("next-auth.session-token")?.value
+ );
+}
+
+// POST — record + dispatch a bulk document download (ADR-0008 convention)
+export async function POST(
+ request: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
+ }
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await request.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ try {
+ const outcome = await triggerBulkDocumentDownload({
+ selection: {
+ scope: body.scope,
+ includeAll: body.includeAll,
+ landlordPropertyIds: body.landlordPropertyIds,
+ portfolioId: Number(portfolioId),
+ },
+ sessionToken: sessionToken(request),
+ });
+ switch (outcome.kind) {
+ case "ok":
+ return NextResponse.json({ taskId: outcome.taskId }, { status: 202 });
+ case "invalid_selection":
+ return NextResponse.json({ error: outcome.message }, { status: 400 });
+ case "backend_error":
+ // Pass the backend's status (400 over-cap / 409 already-started / 5xx)
+ // and its detail message straight through.
+ return NextResponse.json(
+ { error: outcome.message },
+ { status: outcome.status },
+ );
+ }
+ } catch (err) {
+ console.error("POST /bulk-document-download error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx
new file mode 100644
index 00000000..d1961656
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx
@@ -0,0 +1,334 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ PackageCheck,
+ AlertTriangle,
+ X,
+ Download,
+ Loader2,
+ CheckCircle2,
+} from "lucide-react";
+import DocumentTable, { type DocumentSelection } from "./DocumentTable";
+import type {
+ ClassifiedDeal,
+ DocStatusMap,
+ PortfolioCapabilityType,
+ ApprovalsByDeal,
+} from "./types";
+import {
+ MAX_BULK_DOWNLOAD_PROPERTIES,
+ buildSelectionConfig,
+ bulkDownloadRefetchInterval,
+ type BulkDownloadStatus,
+} from "@/lib/bulkDocumentDownload/model";
+
+interface Props {
+ data: ClassifiedDeal[];
+ onOpenDrawer: (
+ dealId: string,
+ uprn: string | null,
+ landlordPropertyId: string | null,
+ dealname: string | null,
+ batch: string | null,
+ batchDescription: string | null,
+ ) => void;
+ docStatusMap: DocStatusMap;
+ portfolioId: string;
+ userCapability: PortfolioCapabilityType;
+ approvalsByDeal?: ApprovalsByDeal;
+ /** Current Documents-tab scope — a real project code or the "__ALL__" sentinel. */
+ currentProjectCode: string;
+}
+
+/** JSON body or null — never throws, so a 504's HTML page can't crash the UI. */
+async function parseBody(res: Response): Promise<{ error?: string; taskId?: string } | null> {
+ const text = await res.text().catch(() => "");
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Bulk document download on the Documents tab: a "Bulk download" button flips the
+ * table into row-selection mode; the picked landlord property ids become a task
+ * whose ZIP is emailed (and shown here while the page stays open). Scoped by the
+ * tab's existing project/group selection.
+ */
+export default function DocumentBulkDownload({
+ data,
+ onOpenDrawer,
+ docStatusMap,
+ portfolioId,
+ userCapability,
+ approvalsByDeal,
+ currentProjectCode,
+}: Props) {
+ const [selecting, setSelecting] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [done, setDone] = useState<{ taskId: string } | null>(null);
+
+ const landlordPropertyIds = [...selectedIds];
+ const built = buildSelectionConfig({
+ scope: { kind: "project", projectCode: currentProjectCode },
+ includeAll: false,
+ landlordPropertyIds,
+ portfolioId: Number(portfolioId),
+ });
+
+ const trigger = useMutation<{ taskId: string }, Error, void>({
+ mutationFn: async () => {
+ const res = await fetch(`/api/portfolio/${portfolioId}/bulk-document-download`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ scope: { kind: "project", projectCode: currentProjectCode },
+ includeAll: false,
+ landlordPropertyIds,
+ }),
+ });
+ const body = await parseBody(res);
+ if (!res.ok) {
+ throw new Error(
+ body?.error ??
+ (res.status >= 500
+ ? "The server didn’t respond in time. Your download may still be preparing — check your email shortly."
+ : `Couldn’t start the download (${res.status}).`),
+ );
+ }
+ if (!body?.taskId) throw new Error("Unexpected response from the server.");
+ return { taskId: body.taskId };
+ },
+ onSuccess: ({ taskId }) => {
+ setDone({ taskId });
+ setSelecting(false);
+ setSelectedIds(new Set());
+ },
+ });
+
+ const status = useQuery({
+ queryKey: ["bulk-download-status", portfolioId, done?.taskId],
+ enabled: !!done,
+ refetchInterval: (d) => bulkDownloadRefetchInterval(d),
+ queryFn: async () => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/bulk-document-download/${done!.taskId}`,
+ );
+ const body = await parseBody(res);
+ if (!res.ok || !body) {
+ throw new Error("Couldn’t check the download status.");
+ }
+ return body as unknown as BulkDownloadStatus;
+ },
+ });
+
+ const selection: DocumentSelection | undefined = selecting
+ ? {
+ selectedIds,
+ onToggle: (id) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ }),
+ onSetMany: (ids, on) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ for (const id of ids) {
+ if (on) next.add(id);
+ else next.delete(id);
+ }
+ return next;
+ }),
+ }
+ : undefined;
+
+ const overCap = selectedIds.size > MAX_BULK_DOWNLOAD_PROPERTIES;
+
+ return (
+
+ {/* Delivery / status card */}
+ {done && (
+ setDone(null)}
+ />
+ )}
+
+ {/* Bulk-download control bar */}
+ {!selecting ? (
+
+
+
+ ) : (
+
+
+ {selectedIds.size} selected
+
+
+ Tick the properties to include, or use the header box to select all.
+
+ {overCap && (
+
+ Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection.
+
+ )}
+ {trigger.isError && (
+ {trigger.error.message}
+ )}
+
From 098a5ccffc31b380d07841fa24bfc5fd53d99d08 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Wed, 8 Jul 2026 16:34:00 +0000
Subject: [PATCH 3/3] fix(live-reporting): bulk download poller no longer hangs
after completion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../live/DocumentBulkDownload.tsx | 25 +++++++++++++++++
src/lib/bulkDocumentDownload/model.test.ts | 17 +++++++++--
src/lib/bulkDocumentDownload/model.ts | 20 +++++++++----
src/lib/bulkDocumentDownload/server.ts | 28 +++++++++++++++----
4 files changed, 78 insertions(+), 12 deletions(-)
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 };
}