Model/backend/app/documents/router.py
Khalim Conn-Kowlessar 90fe2914e0 Select documents by HubSpot project_code(s), unioned with hand-picked property_ids
The 'all properties' selection resolved against property.portfolio_id, but a
portfolio spans multiple HubSpot projects — so 'all' pulled the whole portfolio,
not the project the user was looking at. The project↔property grain lives on
hubspot_deal_data, not property.

task.inputs is now {project_codes?: str[], property_ids?: int[], portfolio_id?}:
the route resolves the distinct landlord_property_id set as the union of every
property in the named project_codes (from hubspot_deal_data) and the hand-picked
property_ids; portfolio_id is optional and only names the package. Drops the
portfolio-scoped select_all. Cap now applies to the resolved set size.

ADR-0060, CONTEXT.md and the request schema updated to match. New router tests
cover project-code resolution, the union+dedup, null landlord_property_id drop,
and the empty-project rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:42:17 +00:00

162 lines
5.9 KiB
Python

"""Bulk Document Download trigger: POST /v1/documents/bulk-download (ADR-0060).
Resolves the authenticated requester's email, resolves + caps the property
selection, pins the recipe onto a single pre-created sub_task under the
app-owned task, and enqueues one message to the bulk_document_download worker.
Never assembles the package synchronously — the worker emails the link.
"""
import json
from collections.abc import Callable, Iterator
from typing import Any, cast
from uuid import uuid4
import boto3
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from backend.app.config import get_settings
from backend.app.db.connection import db_engine
from backend.app.dependencies import validate_jwt_token, validate_token
from backend.app.documents.download_tasks import DocumentDownloadTasks
from backend.app.documents.schemas import BulkDownloadRequest
# The selection cap (ADR-0060) — rejected at the trigger so an oversized request
# never reaches the worker. A conservative, tunable starting value.
MAX_PROPERTIES = 500
MessageSender = Callable[[str], None]
def get_session() -> Iterator[Session]:
with Session(db_engine) as session:
yield session
def get_message_sender() -> MessageSender:
settings = get_settings()
client: Any = cast(Any, boto3.client("sqs", settings.AWS_DEFAULT_REGION)) # pyright: ignore[reportUnknownMemberType]
queue_url = settings.BULK_DOCUMENT_DOWNLOAD_SQS_URL
def send(body: str) -> None:
client.send_message(QueueUrl=queue_url, MessageBody=body)
return send
def get_requesting_user_email(user: Any = Depends(validate_jwt_token)) -> str:
"""The authenticated requester's email — the Download Package recipient.
ADR-0059: the route resolves the user (JWT ``dbId`` -> ``UserModel``) rather
than only gating the token, and threads the email into the job."""
email: Any = getattr(user, "email", None)
if email is None and isinstance(user, dict):
email = cast(dict[str, Any], user).get("email")
if not email and get_settings().ENVIRONMENT == "local":
# Local `get_user` returns a dummy dict with no email; a placeholder lets
# the route be exercised end-to-end on a dev machine (email is a no-op
# locally anyway).
email = "local-dev@example.com"
if not email:
raise HTTPException(
status_code=400,
detail="Could not determine the requesting user's email address.",
)
return str(email)
router = APIRouter(
prefix="/documents",
tags=["documents"],
dependencies=[Depends(validate_token)],
)
@router.post("/bulk-download", status_code=202)
async def bulk_download(
body: BulkDownloadRequest,
session: Session = Depends(get_session),
send_message: MessageSender = Depends(get_message_sender),
recipient_email: str = Depends(get_requesting_user_email),
) -> dict[str, str]:
tasks = DocumentDownloadTasks(session)
if tasks.already_distributed(body.task_id):
raise HTTPException(
status_code=409,
detail=(
f"Task {body.task_id} already has a sub_task — a download has "
"already been started for it."
),
)
config = tasks.read_selection_config(body.task_id)
portfolio_id: Any = config.get("portfolio_id")
raw_project_codes: Any = config.get("project_codes") or []
raw_property_ids: Any = config.get("property_ids") or []
if not isinstance(raw_project_codes, list) or not all(
isinstance(code, str) for code in cast(list[Any], raw_project_codes)
):
raise HTTPException(
status_code=400,
detail="task.inputs project_codes must be a list of strings.",
)
if not isinstance(raw_property_ids, list) or not all(
isinstance(pid, int) for pid in cast(list[Any], raw_property_ids)
):
raise HTTPException(
status_code=400,
detail="task.inputs property_ids must be a list of integers.",
)
project_codes: list[str] = cast(list[str], raw_project_codes)
property_ids: list[int] = cast(list[int], raw_property_ids)
if not project_codes and not property_ids:
raise HTTPException(
status_code=400,
detail="task.inputs must provide project_codes or property_ids.",
)
landlord_property_ids = tasks.resolve_selection(project_codes, property_ids)
if not landlord_property_ids:
raise HTTPException(
status_code=400,
detail=(
"The selection resolves to no downloadable properties — none of "
"the selected projects or properties has a landlord_property_id."
),
)
if len(landlord_property_ids) > MAX_PROPERTIES:
raise HTTPException(
status_code=400,
detail=(
f"The selection has {len(landlord_property_ids)} properties, over "
f"the {MAX_PROPERTIES} limit — narrow your selection."
),
)
subtask_id = uuid4()
package_name = (
f"portfolio-{portfolio_id}-{body.task_id}"
if isinstance(portfolio_id, int)
else f"documents-{body.task_id}"
)
recipe = {
"landlord_property_ids": landlord_property_ids,
"recipient_email": recipient_email,
"package_name": package_name,
}
created = tasks.create_download_subtask(body.task_id, subtask_id, recipe)
if not created:
# Lost a double-submit race — another request already created the
# sub_task. The DB arbitrated; don't enqueue a second job.
raise HTTPException(
status_code=409,
detail=(
f"Task {body.task_id} already has a sub_task — a download has "
"already been started for it."
),
)
send_message(
json.dumps({"task_id": str(body.task_id), "subtask_id": str(subtask_id)})
)
return {"message": "Bulk document download started"}