"""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_landlord_property_ids: Any = config.get("landlord_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_landlord_property_ids, list) or not all( isinstance(lpid, str) for lpid in cast(list[Any], raw_landlord_property_ids) ): raise HTTPException( status_code=400, detail="task.inputs landlord_property_ids must be a list of strings.", ) project_codes: list[str] = cast(list[str], raw_project_codes) selected_landlord_property_ids: list[str] = cast( list[str], raw_landlord_property_ids ) if not project_codes and not selected_landlord_property_ids: raise HTTPException( status_code=400, detail="task.inputs must provide project_codes or landlord_property_ids.", ) landlord_property_ids = tasks.resolve_selection( project_codes, selected_landlord_property_ids ) if not landlord_property_ids: raise HTTPException( status_code=400, detail=( "The selection resolves to no properties — the project codes " "matched nothing and no landlord_property_ids were given." ), ) 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"}