mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-22 08:48:34 +00:00
Add bulk document download as a row-selection flow on the Live Reporting
Documents tab: a "Bulk download" button flips the existing DocumentTable
into selection mode (per-row tickers + select-all across all filtered
pages); "Download selected" zips those properties' documents and emails a
link (shown in-app while the page stays open). Scoped by the tab's
existing Project/group selector.
Two-step trigger (ADR-0008 convention): insert an app-owned tasks row
(task_source "app:bulk_document_download", selection config JSON in
tasks.inputs, source_id = portfolio) then dispatch only { task_id } to
POST /v1/documents/bulk-download. Documents are matched by
landlord_property_id, so the ticked ids ARE the selection — a property
with no landlord id has no documents and is un-tickable.
Resilience: the client now parses responses defensively (a platform 504's
HTML body no longer surfaces as "Unexpected token 'A'…"), and the POST
route sets maxDuration=60 so the synchronous backend resolution has
headroom before the browser gets a 504.
Docs: CONTEXT.md gains Project + Bulk document download; ADR-0011 records
why the marker lives in task_source (worker re-reads the task) vs service.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
/**
|
|
* 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>;
|
|
if (typeof raw.presigned_url !== "string" || !raw.presigned_url) return null;
|
|
return {
|
|
presignedUrl: raw.presigned_url,
|
|
packageS3Key:
|
|
typeof raw.package_s3_key === "string" ? raw.package_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 {
|
|
if (!data) return 4_000;
|
|
return data.state === "preparing" ? 4_000 : false;
|
|
}
|