Model/docs/adr/0060-bulk-document-download-package-model.md
Khalim Conn-Kowlessar 6d001d650e ADR-0059/0060: Bulk Document Download design — SES-SMTP email sender + capped best-effort Download Package 🟪
Grilled 2026-07-08. Locks: app-owned-task + attach-mode lane (ADR-0055);
FastAPI route resolves the requesting user (dbId -> UserModel.email) and pins
it + the resolved property_id set into tasks.inputs; one capped Download
Package = one sub_task; landlord_property_id matching (property_id a future
gap); address-named folders (hubspot-enriched), latest per Document Type,
null-type skipped+reported; best-effort (fail only on infra / wholly-empty);
URL to sub_task.outputs + email; DATA_BUCKET/bulk-downloads/ ~7-day lifecycle.
Backend-sent email via a repositories/email port + infrastructure/email SES-SMTP
adapter. Glossary: Bulk Document Download, Download Package, Document Type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:54:43 +00:00

5.3 KiB

status
accepted (builds on ADR-0055, ADR-0059)

Bulk Document Download builds one capped, best-effort Download Package per request

Users need to pull the documents held in uploaded_files for many properties at once — per property, the latest file of each Document Type — as a single archive, without clicking through the UI file by file. The request is initiated in the front end, can span a hand-picked set of properties or a whole portfolio, and finishes with a link emailed to the requester (ADR-0059).

uploaded_files links to a property by uprn or landlord_property_id (both nullable) and has no property_id column; file_type (the Document Type) is itself nullable; and files live across arbitrary buckets (s3_file_bucket per row).

Decision

A request produces exactly one Download Package: a ZIP of one folder per property, each holding the latest file of each Document Type — built best-effort, size-capped at the trigger, on the app-owned-task + attach-mode lane (ADR-0055).

  • Trigger & lifecycle (ADR-0055). The FastAPI route creates the tasks row, resolves the selection to a concrete property_id set (expanding "all properties" of a portfolio), pins that set plus the recipient email into tasks.inputs (TEXT/JSON — a large id list is why this lives in inputs, not the 256 KB SQS body), pre-creates one sub_task, and drops one SQS message (task_id, sub_task_id). The new applications/bulk_document_download Lambda runs in attach mode; TaskOrchestrator owns status + roll-up.
  • One package = one sub_task. No fan-out: the job streams each document out of its own s3_file_bucket into a single ZIP in /tmp and multipart-uploads it. One artifact, one URL, one email.
  • Selection cap at the route. Reject > N properties synchronously with a legible "narrow your selection" error (property count, not a document/byte query — cheap, no S3 on the hot path). A /tmp size budget inside the Lambda is a backstop that fails the sub_task with a clear reason if a pathological selection still overflows. N starts at a conservative, tunable value.
  • Matching on landlord_property_id. Files are gathered by landlord_property_id; the missing property_id on uploaded_files is a known future gap, out of scope here. Property display info (the folder name) is enriched from the hubspot deals data (repositories/hubspot_deals/).
  • Layout. One folder per property named by human-readable address (hubspot-enriched), with landlord_property_id appended for uniqueness; inside, one file per Document Type = the newest by s3_upload_timestamp. Rows with a null Document Type are skipped and listed in the run output.
  • Best-effort failure contract. Build from whatever resolves. Skipped properties (no documents) and skipped null-type files are recorded in sub_task.outputs. The run fails only on an infrastructure error (S3/DB/zip/upload/email) or when the whole selection yields zero documents. The email carries an "N properties, M documents, X skipped" summary.
  • Delivery — both channels. The presigned URL (60-minute expiry) and the skip-summary are written to sub_task.outputs (the FE already polls task status and can show the link) and emailed (ADR-0059).
  • Retention. Packages are written to DATA_BUCKET under a bulk-downloads/ prefix with an S3 lifecycle rule expiring objects after ~7 days — longer than the 60-minute URL (so a link can be re-issued without rebuilding) but short enough that bundles never accumulate.

Considered options

  • Fan out into per-batch sub_tasks / multiple ZIPs (like the modelling run). Rejected: the user would receive N links / N emails and "the download" would stop being one file; the capped-single-package model keeps the artifact and the notification singular.
  • Cap on resolved document count / bytes. Rejected for v1: it adds synchronous DB + S3 HEAD work to the trigger and couples the route to the packaging logic; a property-count cap is cheap and predictable.
  • Strict "all-or-nothing" packaging. Rejected: one unreadable row or a property with no documents would deny the entire package; best-effort + reporting matches "include documents and properties where they exist".
  • Match on uprn. Deferred: landlord_property_id is the agreed key and hubspot enrichment is keyed to the deal/property; revisit if/when the property_id gap on uploaded_files is closed.

Consequences

  • New DDD pieces: applications/bulk_document_download/ (thin handler + trigger body), orchestration/bulk_document_download_orchestrator.py, packaging rules in domain/, a landlord_property_id-keyed "latest-per-Document-Type" query on the uploaded-file repository, a generate_presigned_url + multipart upload on S3Client, and the email port/adapter from ADR-0059. The only backend/ touch is the trigger route.
  • The stored, pinned property_id set makes a run reproducible even if the portfolio changes between trigger and execution.
  • N, the /tmp size budget, and the 7-day lifecycle are tunable knobs, not load-bearing invariants.
  • Closing the uploaded_files.property_id gap later would let matching move off landlord_property_id without changing the package model.