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>
2.4 KiB
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. Theapp: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. serviceis left null. For a task whose marker already lives intask_source, a second marker inserviceis redundant and can drift. In-app status polling scopes byid = 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
serviceto match Modelling runs. The asymmetry is deliberate: the marker lives where the executor reads it —servicefor a payload-fed distributor,task_sourcefor a task-re-reading worker. - A large selection never travels in an HTTP body: it lives in
inputs, and the only thing dispatched istask_id(ADR-0008 lineage). - Future app-owned, backend-executed tasks should follow this shape:
task_source = "app:<name>", config ininputs,source/source_idfor scoping.