diff --git a/CONTEXT.md b/CONTEXT.md index 33bf900a..de164f7b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -112,6 +112,16 @@ _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution r The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend). _Avoid_: property query, segment, search (the postcode-search journey is unrelated) +### Live projects + +**Project**: +A delivery programme within a Portfolio — a batch of Properties taken through retrofit together — identified by its HubSpot **project code** (`hubspot_deal_data.project_code`; a stable **project id** will replace the code once the HubSpot ops board is settled, so the code is the *current* identifier, not the essence). A Property whose deal carries no project code sits outside every Project (the "Unknown Project" grouping) and cannot be reached by selecting Projects. +_Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-deal field) + +**Bulk document download**: +An app-triggered, asynchronous assembly of a single ZIP of the documents held against a chosen set of Properties — picked as a row selection in the Documents tab, scoped by the tab's current Project/group — delivered as a time-limited link emailed to the requester (and, within the session, retrievable in-app). A selection that matches no documents produces no ZIP: it fails rather than emailing an empty archive. Properties are matched to their documents by **landlord property id**. See [ADR-0011](./docs/adr/0011-app-owned-task-marker-in-task-source.md). +_Avoid_: bulk export, document export, zip download + ## Lifecycle A **BulkUpload** moves through these statuses: diff --git a/docs/adr/0011-app-owned-task-marker-in-task-source.md b/docs/adr/0011-app-owned-task-marker-in-task-source.md new file mode 100644 index 00000000..01534378 --- /dev/null +++ b/docs/adr/0011-app-owned-task-marker-in-task-source.md @@ -0,0 +1,46 @@ +# 11. App-owned, backend-executed tasks carry their marker in `task_source` + +Date: 2026-07-08 + +## Status + +Accepted + +## Context + +Bulk document download follows the app-created-task convention (ADR-0008): Next.js +inserts a `tasks` row whose `inputs` column carries the selection config, then hands +the backend only `{ task_id }`. Unlike a Modelling run — where the distributor +receives the whole config in the dispatch payload and never looks at the task again — +the document worker gets *only* the id, re-reads the `tasks` row, and resolves the +selection from `inputs`. It therefore needs a durable, in-row way to recognise "this +is a bulk-document-download task I'm allowed to act on" before it assembles anything. + +Modelling runs put that marker in `service` (`modelling_run`) and leave `task_source` +human-readable (`"Modelling run – 2 scenarios"`). Reusing `service` here would work, +but it invites a future engineer to "unify" the two conventions and move the marker — +silently breaking the worker's guard. + +## Decision + +- **The marker is `task_source = "app:bulk_document_download"`** — a single, stable + string the worker guards on. The `app:` prefix denotes an app-created task (as + opposed to a backend-created one). It is engineer-facing only (Settings → Logs), so + a machine-shaped string is acceptable; no friendly-label mapping is added. +- **`service` is left null.** For a task whose marker already lives in `task_source`, + a second marker in `service` is redundant and can drift. In-app status polling + scopes by `id = task_id AND source_id = AND task_source = + "app:bulk_document_download"`. +- **Portfolio attachment is unchanged and uniform:** `source = "portfolio_id"`, + `source_id = `. This is what the Portfolio Logs page filters on, so + the run shows up there for free — the same as every other app-owned task. + +## Consequences + +- Do **not** move this marker into `service` to match Modelling runs. The asymmetry is + deliberate: the marker lives where the executor reads it — `service` for a + payload-fed distributor, `task_source` for a task-re-reading worker. +- A large selection never travels in an HTTP body: it lives in `inputs`, and the only + thing dispatched is `task_id` (ADR-0008 lineage). +- Future app-owned, backend-executed tasks should follow this shape: + `task_source = "app:"`, config in `inputs`, `source`/`source_id` for scoping. diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts new file mode 100644 index 00000000..fef05d07 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/[taskId]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { getBulkDownloadStatus } from "@/lib/bulkDocumentDownload/server"; + +// GET — status of a triggered bulk download, for in-app polling. Reads the +// worker's sub_task.outputs (presigned URL, included count, skipped list). +export async function GET( + _request: NextRequest, + props: { params: Promise<{ portfolioId: string; taskId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId, taskId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + try { + const status = await getBulkDownloadStatus(portfolioId, taskId); + if (!status) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(status); + } catch (err) { + console.error("GET /bulk-document-download/[taskId] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts new file mode 100644 index 00000000..564206df --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-document-download/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { triggerBulkDocumentDownload } from "@/lib/bulkDocumentDownload/server"; + +// The document service resolves the selection and creates its sub_task +// synchronously before returning 202, which can outrun the default function +// budget on large projects. Give the round-trip headroom so the browser gets +// our JSON (202 / mapped error) rather than a platform 504. +export const maxDuration = 60; + +const scopeSchema = z.union([ + z.object({ kind: z.literal("project"), projectCode: z.string().min(1) }), + z.object({ + kind: z.literal("all-projects"), + projectCodes: z.array(z.string().min(1)), + }), +]); + +const bodySchema = z.object({ + scope: scopeSchema, + includeAll: z.boolean(), + landlordPropertyIds: z.array(z.string().min(1)).default([]), +}); + +function sessionToken(request: NextRequest): string | undefined { + return ( + request.cookies.get("__Secure-next-auth.session-token")?.value ?? + request.cookies.get("next-auth.session-token")?.value + ); +} + +// POST — record + dispatch a bulk document download (ADR-0008 convention) +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + const outcome = await triggerBulkDocumentDownload({ + selection: { + scope: body.scope, + includeAll: body.includeAll, + landlordPropertyIds: body.landlordPropertyIds, + portfolioId: Number(portfolioId), + }, + sessionToken: sessionToken(request), + }); + switch (outcome.kind) { + case "ok": + return NextResponse.json({ taskId: outcome.taskId }, { status: 202 }); + case "invalid_selection": + return NextResponse.json({ error: outcome.message }, { status: 400 }); + case "backend_error": + // Pass the backend's status (400 over-cap / 409 already-started / 5xx) + // and its detail message straight through. + return NextResponse.json( + { error: outcome.message }, + { status: outcome.status }, + ); + } + } catch (err) { + console.error("POST /bulk-document-download error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx new file mode 100644 index 00000000..d8b174e8 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentBulkDownload.tsx @@ -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>(new Set()); + const [done, setDone] = useState<{ taskId: string } | null>(null); + + const landlordPropertyIds = [...selectedIds]; + const built = buildSelectionConfig({ + scope: { kind: "project", projectCode: currentProjectCode }, + includeAll: false, + landlordPropertyIds, + portfolioId: Number(portfolioId), + }); + + const trigger = useMutation<{ taskId: string }, Error, void>({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/bulk-document-download`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: { kind: "project", projectCode: currentProjectCode }, + includeAll: false, + landlordPropertyIds, + }), + }); + const body = await parseBody(res); + if (!res.ok) { + throw new Error( + body?.error ?? + (res.status >= 500 + ? "The server didn’t respond in time. Your download may still be preparing — check your email shortly." + : `Couldn’t start the download (${res.status}).`), + ); + } + if (!body?.taskId) throw new Error("Unexpected response from the server."); + return { taskId: body.taskId }; + }, + onSuccess: ({ taskId }) => { + setDone({ taskId }); + setSelecting(false); + setSelectedIds(new Set()); + }, + }); + + const status = useQuery({ + queryKey: ["bulk-download-status", portfolioId, done?.taskId], + enabled: !!done, + refetchInterval: (d) => bulkDownloadRefetchInterval(d), + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-document-download/${done!.taskId}`, + ); + const body = await parseBody(res); + if (!res.ok || !body) { + throw new Error("Couldn’t check the download status."); + } + return body as unknown as BulkDownloadStatus; + }, + }); + + const selection: DocumentSelection | undefined = selecting + ? { + selectedIds, + onToggle: (id) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }), + onSetMany: (ids, on) => + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + return next; + }), + } + : undefined; + + const overCap = selectedIds.size > MAX_BULK_DOWNLOAD_PROPERTIES; + + return ( +
+ {/* Delivery / status card */} + {done && ( + setDone(null)} + /> + )} + + {/* Bulk-download control bar */} + {!selecting ? ( +
+ +
+ ) : ( +
+ + {selectedIds.size} selected + + + Tick properties, or use the header box to select the page (then “select + all across pages”). + + {overCap && ( + + Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection. + + )} + {trigger.isError && ( + {trigger.error.message} + )} +
+ + +
+
+ )} + + +
+ ); +} + +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 ( +
+ +
+

Your download is ready

+

+ We’ve emailed you the link — it’s valid for about 60 minutes. +

+
+ +
+ ); + } + + if (state === "ready" && status?.delivery) { + const { presignedUrl, included, skipped } = status.delivery; + return ( +
+
+ +
+

Your download is ready

+

+ {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. +

+
+ + + Download ZIP + +
+ {skipped.length > 0 && ( +
+ + {showSkipped && ( +
    + {skipped.map((s, i) => ( +
  • + {s.landlord_property_id} — {s.reason} +
  • + ))} +
+ )} +
+ )} +
+ ); + } + + if (state === "failed" || isError) { + return ( +
+ +
+

+ We couldn’t build this download +

+

+ The selection may contain no documents. Try a different selection. +

+
+ +
+ ); + } + + return ( +
+ +
+

+ Your download is being prepared — we’ll email you a link +

+

+ You can leave this page; the link will be in your inbox, and it’ll appear here + if you stay. +

+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx index cd09460e..ec50d043 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -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; + 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("all"); const [installStatusFilter, setInstallStatusFilter] = useState("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[] = useMemo(() => { + if (!selection) return baseColumns; + const selectColumn: ColumnDef = { + 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 ( + { + 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 ( + + ); + } + return ( + 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 = { 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"}

+ {/* Select-all-across-pages banner */} + {showSelectAllBanner && ( +
+ {allFilteredSelected ? ( + <> + + All {filteredSelectableIds.length}{" "} + matching propert{filteredSelectableIds.length === 1 ? "y is" : "ies are"}{" "} + selected. + + + + ) : ( + <> + + All {pageSelectableIds.length} on this + page selected. + + + + )} +
+ )} + {/* Table */}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index b8a67a3a..1318ba79 100644 Binary files a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx and b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx differ diff --git a/src/lib/bulkDocumentDownload/model.test.ts b/src/lib/bulkDocumentDownload/model.test.ts new file mode 100644 index 00000000..c2d9d007 --- /dev/null +++ b/src/lib/bulkDocumentDownload/model.test.ts @@ -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); + }); +}); diff --git a/src/lib/bulkDocumentDownload/model.ts b/src/lib/bulkDocumentDownload/model.ts new file mode 100644 index 00000000..3eee8c60 --- /dev/null +++ b/src/lib/bulkDocumentDownload/model.ts @@ -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; + // 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; +} diff --git a/src/lib/bulkDocumentDownload/server.ts b/src/lib/bulkDocumentDownload/server.ts new file mode 100644 index 00000000..88292f70 --- /dev/null +++ b/src/lib/bulkDocumentDownload/server.ts @@ -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 { + try { + const body = (await res.json()) as Record; + 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 { + 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 { + 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 { + 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 }; +}