Merge pull request #376 from Hestia-Homes/feature/bulk-document-download

feat(live-reporting): bulk document download (Documents tab)
This commit is contained in:
KhalimCK 2026-07-08 17:40:53 +01:00 committed by GitHub
commit 8bbcd77974
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1216 additions and 2 deletions

View file

@ -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:

View file

@ -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 = <portfolio> AND task_source =
"app:bulk_document_download"`.
- **Portfolio attachment is unchanged and uniform:** `source = "portfolio_id"`,
`source_id = <portfolio_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:<name>"`, config in `inputs`, `source`/`source_id` for scoping.

View file

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

View file

@ -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<typeof bodySchema>;
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 });
}
}

View file

@ -0,0 +1,360 @@
"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<Set<string>>(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 didnt respond in time. Your download may still be preparing — check your email shortly."
: `Couldnt 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<BulkDownloadStatus, Error>({
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("Couldnt 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 (
<div className="space-y-3">
{/* Delivery / status card */}
{done && (
<DeliveryCard
status={status.data}
isError={status.isError}
onDismiss={() => setDone(null)}
/>
)}
{/* Bulk-download control bar */}
{!selecting ? (
<div className="flex items-center justify-between gap-3">
<button
onClick={() => setSelecting(true)}
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg border border-brandblue/20 bg-white text-sm font-medium text-brandblue hover:border-brandblue/40 hover:bg-brandlightblue/10 transition-colors"
>
<PackageCheck className="h-4 w-4" />
Bulk download
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-brandblue/20 bg-brandlightblue/10 px-4 py-2.5">
<span className="text-sm font-semibold text-brandblue">
{selectedIds.size} selected
</span>
<span className="text-xs text-gray-500">
Tick properties, or use the header box to select the page (then select
all across pages).
</span>
{overCap && (
<span className="text-xs font-medium text-red-600">
Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit narrow your selection.
</span>
)}
{trigger.isError && (
<span className="text-xs font-medium text-red-600">{trigger.error.message}</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => {
setSelecting(false);
setSelectedIds(new Set());
trigger.reset();
}}
className="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"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
<button
onClick={() => trigger.mutate()}
disabled={!built.ok || trigger.isLoading}
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-brandblue text-sm font-semibold text-white hover:bg-brandblue/90 disabled:opacity-40 transition-colors"
>
{trigger.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{trigger.isLoading ? "Starting…" : "Download selected"}
</button>
</div>
</div>
)}
<DocumentTable
data={data}
onOpenDrawer={onOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
selection={selection}
/>
</div>
);
}
function DeliveryCard({
status,
isError,
onDismiss,
}: {
status: BulkDownloadStatus | undefined;
isError: boolean;
onDismiss: () => void;
}) {
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 (
<div
role="status"
className="rounded-xl border border-emerald-200 bg-emerald-50/70 px-4 py-3"
>
<div className="flex flex-wrap items-center gap-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">
{included} {included === 1 ? "document" : "documents"} included
{skipped.length > 0 && ` · ${skipped.length} skipped`}. Weve also emailed
the link its valid for about 60 minutes.
</p>
</div>
<a
href={presignedUrl}
className="ml-auto inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-brandblue text-sm font-semibold text-white hover:bg-brandblue/90 transition-colors"
>
<Download className="h-4 w-4" />
Download ZIP
</a>
</div>
{skipped.length > 0 && (
<div className="mt-2">
<button
onClick={() => setShowSkipped((s) => !s)}
className="text-[12.5px] font-semibold text-brandmidblue hover:underline"
>
{showSkipped ? "Hide" : "Show"} {skipped.length} skipped
</button>
{showSkipped && (
<ul className="mt-1.5 max-h-40 overflow-y-auto rounded-lg border border-gray-100 bg-white px-3 py-2 text-[12.5px] text-gray-600">
{skipped.map((s, i) => (
<li key={`${s.landlord_property_id}-${i}`}>
<b className="font-semibold text-gray-700">{s.landlord_property_id}</b> {s.reason}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
if (state === "failed" || isError) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3"
>
<AlertTriangle className="h-5 w-5 flex-none text-amber-500" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">
We couldnt build this download
</p>
<p className="text-[13px] text-gray-600">
The selection may contain no documents. Try a different selection.
</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>
);
}
return (
<div
role="status"
className="flex items-center gap-3 rounded-xl border border-brandblue/20 bg-brandlightblue/10 px-4 py-3"
>
<Loader2 className="h-5 w-5 flex-none animate-spin text-brandmidblue" aria-hidden />
<div>
<p className="text-sm font-semibold text-brandblue">
Your download is being prepared well email you a link
</p>
<p className="text-[13px] text-gray-500">
You can leave this page; the link will be in your inbox, and itll appear here
if you stay.
</p>
</div>
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
flexRender,
type SortingState,
type PaginationState,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
@ -34,6 +35,18 @@ import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsBy
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
/**
* Row-selection wiring for bulk document download. When present, the table
* shows a tick column; select-all operates over every filtered row (all pages),
* not just the visible page. Rows without a landlord property id are un-tickable
* documents are matched by landlord property id, so there is nothing to zip.
*/
export interface DocumentSelection {
selectedIds: Set<string>;
onToggle: (landlordPropertyId: string) => void;
onSetMany: (landlordPropertyIds: string[], selected: boolean) => void;
}
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void;
@ -41,6 +54,7 @@ interface DocumentTableProps {
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
selection?: DocumentSelection;
}
function escapeCell(value: unknown): string {
@ -54,7 +68,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal, selection }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
@ -87,7 +101,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
});
}, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]);
const columns = useMemo(
const baseColumns = useMemo(
() => createDocumentTableColumns(
onOpenDrawer,
docStatusMap,
@ -96,6 +110,60 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
[onOpenDrawer, docStatusMap, userCapability],
);
// Prepend a tick column when in selection mode. Recomputed when `selection`
// changes (a fresh Set per toggle) so checkbox state stays live.
const columns: ColumnDef<ClassifiedDeal>[] = useMemo(() => {
if (!selection) return baseColumns;
const selectColumn: ColumnDef<ClassifiedDeal> = {
id: "select",
enableSorting: false,
enableGlobalFilter: false,
header: ({ table }) => {
// Header ticks the current page; the banner below offers all pages.
const pageIds = table
.getRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id);
const allSelected =
pageIds.length > 0 && pageIds.every((id) => selection.selectedIds.has(id));
const someSelected = pageIds.some((id) => selection.selectedIds.has(id));
return (
<input
type="checkbox"
aria-label="Select all properties on this page"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={() => selection.onSetMany(pageIds, !allSelected)}
className="h-4 w-4 accent-brandblue align-middle"
/>
);
},
cell: ({ row }) => {
const id = row.original.landlordPropertyId;
if (!id) {
return (
<span
title="No landlord property id — no documents to include"
className="inline-block h-4 w-4 rounded border border-gray-200 bg-gray-100 align-middle"
/>
);
}
return (
<input
type="checkbox"
aria-label={`Select ${id}`}
checked={selection.selectedIds.has(id)}
onChange={() => selection.onToggle(id)}
className="h-4 w-4 accent-brandblue align-middle"
/>
);
},
};
return [selectColumn, ...baseColumns];
}, [baseColumns, selection]);
const table = useReactTable({
data: filteredData,
columns,
@ -151,6 +219,31 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
// "Select all across pages" banner: shown when the whole current page is
// ticked and there are more filtered rows than fit on it (Gmail-style).
const pageSelectableIds = selection
? table
.getRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id)
: [];
const filteredSelectableIds = selection
? table
.getFilteredRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id)
: [];
const pageFullySelected =
pageSelectableIds.length > 0 &&
pageSelectableIds.every((id) => selection!.selectedIds.has(id));
const allFilteredSelected =
filteredSelectableIds.length > 0 &&
filteredSelectableIds.every((id) => selection!.selectedIds.has(id));
const showSelectAllBanner =
!!selection &&
pageFullySelected &&
filteredSelectableIds.length > pageSelectableIds.length;
const retroAssessmentLabel: Record<RetroAssessmentFilter, string> = {
all: "All retrofit statuses",
none: "No Retrofit Docs",
@ -248,6 +341,40 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
{/* Select-all-across-pages banner */}
{showSelectAllBanner && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-brandblue/20 bg-brandlightblue/20 px-3.5 py-2 text-[13px] text-brandblue">
{allFilteredSelected ? (
<>
<span>
All <span className="font-semibold">{filteredSelectableIds.length}</span>{" "}
matching propert{filteredSelectableIds.length === 1 ? "y is" : "ies are"}{" "}
selected.
</span>
<button
onClick={() => selection!.onSetMany(filteredSelectableIds, false)}
className="font-semibold underline hover:text-brandblue/80"
>
Clear selection
</button>
</>
) : (
<>
<span>
All <span className="font-semibold">{pageSelectableIds.length}</span> on this
page selected.
</span>
<button
onClick={() => selection!.onSetMany(filteredSelectableIds, true)}
className="font-semibold underline hover:text-brandblue/80"
>
Select all {filteredSelectableIds.length} across pages
</button>
</>
)}
</div>
)}
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">

View file

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
MAX_BULK_DOWNLOAD_PROPERTIES,
buildBulkDownloadRecord,
buildSelectionConfig,
bulkDownloadRefetchInterval,
parseDelivery,
} from "./model";
describe("buildSelectionConfig — UI selection → stored config", () => {
const portfolioId = 814;
it("one project + include all → project_codes", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "ABRI-2024-01" },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { project_codes: ["ABRI-2024-01"], portfolio_id: 814 },
});
});
it("one project + ticked subset → landlord_property_ids (scope ignored)", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "ABRI-2024-01" },
includeAll: false,
landlordPropertyIds: ["LP1023", "LP1044"],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1023", "LP1044"], portfolio_id: 814 },
});
});
it("all projects + include all → every project code", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { project_codes: ["A-1", "B-2"], portfolio_id: 814 },
});
});
it("all projects + ticked subset → landlord_property_ids", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] },
includeAll: false,
landlordPropertyIds: ["LP1"],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1"], portfolio_id: 814 },
});
});
it("de-duplicates ids and codes", () => {
expect(
buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "A-1"] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
}),
).toEqual({ ok: true, config: { project_codes: ["A-1"], portfolio_id: 814 } });
expect(
buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: ["LP1", "LP1", " LP1 "],
portfolioId,
}),
).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1"], portfolio_id: 814 },
});
});
it("rejects an empty subset", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: [],
portfolioId,
});
expect(r.ok).toBe(false);
});
it("rejects include-all with no usable codes", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["", " "] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r.ok).toBe(false);
});
it("rejects a subset over the 500 cap with the detail message", () => {
const ids = Array.from({ length: 501 }, (_, i) => `LP${i}`);
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: ids,
portfolioId,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain(`over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit`);
expect(r.error).toContain("501 properties");
}
});
});
describe("buildBulkDownloadRecord", () => {
it("writes the app-owned task marker and the config as JSON inputs", () => {
const record = buildBulkDownloadRecord({
portfolioId: "814",
config: { project_codes: ["A-1"], portfolio_id: 814 },
});
expect(record.taskSource).toBe("app:bulk_document_download");
expect(record.source).toBe("portfolio_id");
expect(record.sourceId).toBe("814");
expect(record.status).toBe("waiting");
expect(JSON.parse(record.inputs)).toEqual({
project_codes: ["A-1"],
portfolio_id: 814,
});
});
});
describe("parseDelivery", () => {
it("reads a finished delivery", () => {
const d = parseDelivery(
JSON.stringify({
presigned_url: "https://x",
package_s3_key: "k",
included: 137,
skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }],
}),
);
expect(d).toEqual({
presignedUrl: "https://x",
packageS3Key: "k",
included: 137,
skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }],
});
});
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();
expect(parseDelivery("{not json")).toBeNull();
expect(parseDelivery(JSON.stringify({ included: 0 }))).toBeNull();
});
});
describe("bulkDownloadRefetchInterval", () => {
it("polls while preparing, stops once resolved", () => {
expect(bulkDownloadRefetchInterval(undefined)).toBe(8_000);
expect(bulkDownloadRefetchInterval({ state: "preparing", delivery: null })).toBe(8_000);
expect(
bulkDownloadRefetchInterval({
state: "ready",
delivery: { presignedUrl: "x", packageS3Key: null, included: 1, skipped: [] },
}),
).toBe(false);
expect(bulkDownloadRefetchInterval({ state: "failed", delivery: null })).toBe(false);
});
});

View file

@ -0,0 +1,194 @@
/**
* Bulk-document-download domain model pure logic for the Documents bulk
* download. Mirrors the Modelling-run trigger convention (ADR-0008): one
* app-authored `tasks` row whose `inputs` carries the selection config, then
* the backend is handed only the task id.
*
* The backend resolves the union of (all properties in the given project
* codes) and (the hand-picked landlord property ids), de-duplicated. Rule of
* thumb: "include all" sends `project_codes` (so properties without a
* landlord_property_id are still swept in); a ticked subset sends
* `landlord_property_ids`. "All projects" enumerates the portfolio's project
* codes there is no server-side "whole portfolio" option by design.
*/
/** Backend cap; over this the trigger is rejected with a 400. */
export const MAX_BULK_DOWNLOAD_PROPERTIES = 500;
/**
* The selection config, exactly as it is stored (JSON) in `tasks.inputs` and
* read back by the worker. snake_case because the backend parses it directly
* do not add app-provenance keys here (the recipient email is resolved from
* the JWT at dispatch, not carried in the config).
*/
export interface BulkDownloadConfig {
/** HubSpot project codes (hubspot_deal_data.project_code). */
project_codes?: string[];
/** The hand-pick key (property.landlord_property_id). */
landlord_property_ids?: string[];
/** Names the ZIP only. */
portfolio_id?: number;
}
/** Which projects the download is scoped to. */
export type BulkDownloadScope =
| { kind: "project"; projectCode: string }
| { kind: "all-projects"; projectCodes: string[] };
/** The UI's selection intent, resolved into a config server-side. */
export interface BulkDownloadSelectionInput {
scope: BulkDownloadScope;
/** true → "include all" (send project_codes); false → send the ticked ids. */
includeAll: boolean;
/** The ticked landlord_property_ids — used only when includeAll is false. */
landlordPropertyIds: string[];
portfolioId: number;
}
export type BuildConfigResult =
| { ok: true; config: BulkDownloadConfig }
| { ok: false; error: string };
/**
* Map the UI selection onto the stored config:
* include all { project_codes, portfolio_id }
* ticked subset { landlord_property_ids, portfolio_id }
* At least one of project_codes / landlord_property_ids must be non-empty.
*/
export function buildSelectionConfig(
input: BulkDownloadSelectionInput,
): BuildConfigResult {
const portfolio_id = input.portfolioId;
if (input.includeAll) {
const rawCodes =
input.scope.kind === "project"
? [input.scope.projectCode]
: input.scope.projectCodes;
const project_codes = [
...new Set(rawCodes.map((c) => c.trim()).filter((c) => c.length > 0)),
];
if (project_codes.length === 0) {
return { ok: false, error: "Pick at least one project to download." };
}
return { ok: true, config: { project_codes, portfolio_id } };
}
const landlord_property_ids = [
...new Set(input.landlordPropertyIds.map((i) => i.trim()).filter(Boolean)),
];
if (landlord_property_ids.length === 0) {
return {
ok: false,
error: "Tick at least one property, or choose “include all”.",
};
}
if (landlord_property_ids.length > MAX_BULK_DOWNLOAD_PROPERTIES) {
return {
ok: false,
error: `The selection has ${landlord_property_ids.length} properties, over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection.`,
};
}
return { ok: true, config: { landlord_property_ids, portfolio_id } };
}
/**
* The marker the worker keys off, in `task_source` (ADR-0011). It receives only
* the task id, re-reads this row, and guards on this value before assembling.
*/
export const BULK_DOWNLOAD_TASK_SOURCE = "app:bulk_document_download";
/**
* The row a bulk download persists: an app-authored task whose `inputs` carries
* the selection config. Single marker in `task_source` (ADR-0011) `service`
* is left null; `source`/`source_id` scope it to the Portfolio (and attach it
* to the portfolio's logs).
*/
export function buildBulkDownloadRecord(args: {
portfolioId: string;
config: BulkDownloadConfig;
}): {
taskSource: typeof BULK_DOWNLOAD_TASK_SOURCE;
source: "portfolio_id";
sourceId: string;
status: "waiting";
inputs: string;
} {
return {
taskSource: BULK_DOWNLOAD_TASK_SOURCE,
source: "portfolio_id",
sourceId: args.portfolioId,
status: "waiting",
inputs: JSON.stringify(args.config),
};
}
// ── Delivery / status (optional in-app polling) ──────────────────────────────
export interface BulkDownloadSkipped {
landlord_property_id: string;
s3_key: string;
reason: string;
}
export interface BulkDownloadDelivery {
/** ZIP link, valid 60 minutes. */
presignedUrl: string;
packageS3Key: string | null;
/** Documents included. */
included: number;
skipped: BulkDownloadSkipped[];
}
export type BulkDownloadState = "preparing" | "ready" | "failed";
export interface BulkDownloadStatus {
state: BulkDownloadState;
delivery: BulkDownloadDelivery | null;
}
/**
* Read the worker's `sub_task.outputs` back into the app's shape. Null for
* anything that isn't a finished delivery (in-flight, malformed) polling must
* never throw on an unfinished row.
*/
export function parseDelivery(outputs: string | null): BulkDownloadDelivery | null {
if (!outputs) return null;
try {
const raw = JSON.parse(outputs) as Record<string, unknown>;
// 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: url,
packageS3Key:
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[])
: [],
};
} catch {
return null;
}
}
/**
* Poll cadence: keep checking while the ZIP is being built, stop once it is
* ready or failed. Shape matches TanStack v4's refetchInterval callback, which
* receives the query data first.
*/
export function bulkDownloadRefetchInterval(
data: BulkDownloadStatus | undefined,
): number | 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

@ -0,0 +1,171 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { and, desc, eq } from "drizzle-orm";
import {
BULK_DOWNLOAD_TASK_SOURCE,
BulkDownloadSelectionInput,
BulkDownloadStatus,
buildBulkDownloadRecord,
buildSelectionConfig,
parseDelivery,
} from "./model";
// The document-service entry point on the backend. It resolves the selection
// from the task's `inputs`, assembles the ZIP, emails the link (recipient from
// the JWT), and writes the result to the task's sub_task.outputs.
const BULK_DOWNLOAD_ENDPOINT = "/v1/documents/bulk-download";
type Dispatch = { ok: true } | { ok: false; status: number; message: string };
function isFailed(status: string | null): boolean {
return status ? ["failed", "failure", "error"].includes(status.toLowerCase()) : false;
}
function defaultMessageForStatus(status: number): string {
if (status === 409) return "A download for this selection has already been started.";
if (status === 400) return "That selection cant be downloaded — please adjust it and try again.";
return "Couldnt start the download — please try again.";
}
/** Pull FastAPI's error detail so a 400 (e.g. over the 500 cap) reaches the user verbatim. */
async function extractDetail(res: Response): Promise<string> {
try {
const body = (await res.json()) as Record<string, unknown>;
const d = body.detail ?? body.message ?? body.error;
if (typeof d === "string" && d.trim()) return d;
if (Array.isArray(d) && typeof (d[0] as { msg?: unknown })?.msg === "string") {
return (d[0] as { msg: string }).msg;
}
} catch {
// not JSON — fall through to the status default
}
return defaultMessageForStatus(res.status);
}
async function dispatchBulkDownload(
taskId: string,
sessionToken: string | undefined,
): Promise<Dispatch> {
const url = process.env.FASTAPI_API_URL;
const key = process.env.FASTAPI_API_KEY;
if (!url || !key) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return { ok: false, status: 500, message: "Server misconfiguration" };
}
try {
const res = await fetch(`${url}${BULK_DOWNLOAD_ENDPOINT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
// The recipient email is resolved from this JWT; no email in the body.
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({ task_id: taskId }),
});
if (res.ok) return { ok: true };
return { ok: false, status: res.status, message: await extractDetail(res) };
} catch (err) {
console.error(`Failed to reach FastAPI ${BULK_DOWNLOAD_ENDPOINT}:`, err);
return {
ok: false,
status: 502,
message: "Couldnt reach the document service — please try again.",
};
}
}
export type BulkDownloadTriggerOutcome =
| { kind: "ok"; taskId: string }
| { kind: "invalid_selection"; message: string }
| { kind: "backend_error"; status: number; message: string };
/**
* Record + dispatch a bulk document download (Modelling-run convention,
* ADR-0008): one app-authored task whose `inputs` carries the selection
* config, then the document service is handed only the task id so a large
* selection never travels in the HTTP body. A rejected dispatch marks the task
* failed so it never lingers as a ghost.
*/
export async function triggerBulkDocumentDownload(args: {
selection: BulkDownloadSelectionInput;
sessionToken: string | undefined;
}): Promise<BulkDownloadTriggerOutcome> {
const built = buildSelectionConfig(args.selection);
if (!built.ok) return { kind: "invalid_selection", message: built.error };
const record = buildBulkDownloadRecord({
portfolioId: String(args.selection.portfolioId),
config: built.config,
});
const now = new Date();
const [task] = await db
.insert(tasks)
.values({ ...record, jobStarted: now })
.returning();
const dispatch = await dispatchBulkDownload(task.id, args.sessionToken);
if (!dispatch.ok) {
await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id));
return { kind: "backend_error", status: dispatch.status, message: dispatch.message };
}
await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id));
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. 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,
taskId: string,
): Promise<BulkDownloadStatus | null> {
const rows = await db
.select({
taskStatus: tasks.status,
taskDone: tasks.jobCompleted,
subStatus: subTasks.status,
subDone: subTasks.jobCompleted,
outputs: subTasks.outputs,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(
and(
eq(tasks.id, taskId),
eq(tasks.taskSource, BULK_DOWNLOAD_TASK_SOURCE),
eq(tasks.sourceId, portfolioId),
),
)
.orderBy(desc(subTasks.updatedAt));
if (rows.length === 0) return null;
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 || complete ? "ready" : failed ? "failed" : "preparing";
return { state, delivery };
}