mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
Merge pull request #376 from Hestia-Homes/feature/bulk-document-download
feat(live-reporting): bulk document download (Documents tab)
This commit is contained in:
commit
8bbcd77974
10 changed files with 1216 additions and 2 deletions
10
CONTEXT.md
10
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:
|
||||
|
|
|
|||
46
docs/adr/0011-app-owned-task-marker-in-task-source.md
Normal file
46
docs/adr/0011-app-owned-task-marker-in-task-source.md
Normal 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.
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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<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("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 (
|
||||
<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">
|
||||
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 (
|
||||
<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`}. We’ve also emailed
|
||||
the link — it’s 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 couldn’t 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 — we’ll 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 it’ll appear here
|
||||
if you stay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Binary file not shown.
193
src/lib/bulkDocumentDownload/model.test.ts
Normal file
193
src/lib/bulkDocumentDownload/model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
194
src/lib/bulkDocumentDownload/model.ts
Normal file
194
src/lib/bulkDocumentDownload/model.ts
Normal 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;
|
||||
}
|
||||
171
src/lib/bulkDocumentDownload/server.ts
Normal file
171
src/lib/bulkDocumentDownload/server.ts
Normal 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 can’t be downloaded — please adjust it and try again.";
|
||||
return "Couldn’t 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: "Couldn’t 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue