8.9 KiB
PRD: Ventilation Audit Generator from MagicPlan
Problem Statement
When a surveyor completes a MagicPlan survey for a property, the resulting floor plan data (rooms, windows, doors, ventilation measurements) needs to be transformed into a structured ventilation audit spreadsheet. Currently this transformation is manual — someone must extract plan data and populate a report by hand, which is slow and error-prone.
Solution
An AWS Lambda (audit-generator) triggered via SQS receives a HubSpot deal ID, fetches the parsed MagicPlan Plan from the database, populates a pre-formatted .xlsx template with plan data, uploads the result to S3, and records it in uploaded_files. The populated spreadsheet is then accessible to the UI so the user knows an audit file exists for that deal.
User Stories
- As a coordinator, I want clicking a button in the UI to trigger generation of a ventilation audit spreadsheet, so that I do not have to manually populate it from the floor plan.
- As a coordinator, I want the audit spreadsheet to be automatically populated with room, window, and door data from the MagicPlan survey, so that the data entry step is eliminated.
- As a coordinator, I want the system to use a pre-formatted
.xlsxtemplate when generating the audit, so that conditional formatting and layout are preserved without requiring code changes. - As a coordinator, I want the UI to indicate whether a ventilation audit already exists for a deal, so that I avoid triggering duplicate generation unnecessarily.
- As a coordinator, I want re-triggering generation to overwrite the previous audit file, so that I can regenerate after a corrected survey is uploaded.
- As an engineer, I want the lambda to raise a clear error if no MagicPlan JSON has been uploaded for the deal, so that misconfigured triggers are diagnosed quickly.
- As an engineer, I want the lambda to raise a distinct error if a MagicPlan JSON exists but has not yet been parsed into the database, so that timing issues are distinguishable from missing data.
- As an engineer, I want the generated spreadsheet recorded in
uploaded_fileswith aVENTILATION_AUDITfile type, so that the UI and other systems can query for its existence. - As an engineer, I want the audit template to be resolved from an environment variable, so that different templates can be used in staging and production without a code deploy.
- As an engineer, I want the lambda to follow the
@subtask_handler()pattern, so that it integrates with the task orchestration system and benefits from standard error handling and observability. - As an engineer, I want the spreadsheet cells to be written via named ranges defined in the template, so that template layout changes do not require code changes.
Implementation Decisions
-
Lambda pattern:
@subtask_handler()decorator. Trigger body containstask_id,sub_task_id, andhubspot_deal_id. -
MAGIC_PLAN_JSON lookup: Query
uploaded_filesfiltered byhubspot_deal_idandfile_type = MAGIC_PLAN_JSON, ordered bys3_upload_timestamp DESC, taking the most recent row. Rationale: a re-upload supersedes the earlier file. -
Plan retrieval: Use the existing
MagicPlanPostgresRepository.get_plan_by_uploaded_file_idto fetch the parsed domainPlanfrom postgres. The lambda does not re-parse from S3 — that is the magic_plan lambda's responsibility. -
Error handling — two distinct cases:
- No
uploaded_filesrow found → raise with message indicating no MagicPlan has been uploaded for this deal. - Row found but
get_plan_by_uploaded_file_idreturnsNone→ raise with message indicating the plan has been uploaded but not yet parsed. - Both use the same exception type; distinct messages enable diagnosis in CloudWatch.
- No
-
Spreadsheet generation:
- Format:
.xlsxviaopenpyxl. - The template
.xlsxis downloaded from S3 at the key given by env varAUDIT_TEMPLATE_S3_KEY. - The template is loaded into memory (
openpyxl.load_workbook(BytesIO(template_bytes))), populated, and serialised back to bytes for upload. - Cell targeting uses named ranges defined in the template (
workbook.defined_names). The initial stub implementation may use fixed cell addresses as a placeholder until the template is finalised. - The template does not exist yet. For the initial implementation, the template download and population step is stubbed — the lambda generates a minimal valid
.xlsx(e.g. one row per room with name and area) without a template.
- Format:
-
Output S3 key:
documents/hubspot_deal_id/{hubspot_deal_id}/ventilation_audit.xlsx. Re-running the lambda overwrites the previous file. -
Operation order: S3 upload first, then
uploaded_filesDB insert. An orphaned S3 file on DB failure is harmless and will be overwritten on retry. A DB record pointing to a non-existent file is worse. -
New enum values (added to
FileTypeEnumandFileSourceEnum):FileTypeEnum.VENTILATION_AUDIT = "ventilation_audit"FileSourceEnum.AUDIT_GENERATOR = "audit_generator"
-
New
UploadedFileRepository: A new repository (UploadedFilePostgresRepository) is introduced with at minimum aget_latest_magic_plan_json_by_hubspot_deal_id(hubspot_deal_id: str) -> Optional[UploadedFile]method. This queries the existinguploaded_filestable via the existing SQLAlchemy model inbackend/app/db/models/uploaded_file.py. Full DDD migration of theUploadedFilemodel toinfrastructure/postgres/is out of scope for this PR. -
Idempotency: No duplicate guard. The lambda always overwrites and always inserts a new
uploaded_filesrow. The UI surfaces whether a record exists; the timestamp on the most recent row is authoritative. -
Environment variables:
S3_BUCKET_NAME(shared convention)AUDIT_TEMPLATE_S3_KEY(template location)DATABASE_URL(shared convention)
-
Trigger: The SQS message is sent by a UI action in a separate repo. No SQS publishing client is required in this PR.
Testing Decisions
Good tests assert observable outputs given controlled inputs — they do not assert on internal call sequences or implementation details. Prefer mocking at the boundary of the system under test, not inside it.
Handler tests (tests/applications/audit_generator/test_audit_generator_handler.py):
- Test that an invalid trigger body raises
ValidationError. - Test that the orchestrator is constructed with values derived from env vars and the trigger body.
- Test that the handler returns the expected value on success.
- Use
handler.__wrapped__to bypass the@subtask_handlerdecorator (prior art:test_magic_plan_handler.py).
Orchestrator tests (tests/orchestration/audit_generator/test_audit_generator_orchestrator.py):
- Mock
S3Client,UploadedFileRepository, andMagicPlanPostgresRepositorywithMagicMock(spec=...). - Test happy path: correct S3 key used for output upload;
uploaded_filesinsert called with correctfile_typeandfile_source. - Test error path: raises with appropriate message when no
uploaded_filesrow found. - Test error path: raises with appropriate message when plan not in postgres.
- Prior art:
tests/orchestration/magic_plan/test_magic_plan_orchestrator.py.
Repository tests (tests/repositories/uploaded_file/test_uploaded_file_postgres_repository.py):
- Integration tests against a real postgres
Engine(prior art:tests/repositories/magic_plan/test_magic_plan_postgres_repository.py). - Test that
get_latest_magic_plan_json_by_hubspot_deal_idreturns the most recent row bys3_upload_timestampwhen multiple rows exist. - Test that it returns
Nonewhen no matching row exists.
Out of Scope
- The SQS trigger — the UI button that sends the SQS message lives in a separate repo.
- The
.xlsxtemplate file itself — the template does not yet exist; the initial implementation stubs this step. - Full DDD migration of the
UploadedFileSQLAlchemy model frombackend/app/db/models/toinfrastructure/postgres/— this is a separate refactoring task. - Named-range-based cell targeting — the stub uses fixed addresses or a minimal generated workbook; named ranges are the target interface once the template is designed.
- Any ventilation calculation or compliance logic — the spreadsheet is populated with raw plan data only.
Further Notes
- The
audit-generatorapplication scaffold already exists atapplications/audit-generator/with emptyhandler.pyandaudit_generator_trigger_request.pyfiles. - The
MagicPlanPostgresRepository.get_plan_by_uploaded_file_idmethod (introduced in recent commits) is the correct entry point for fetching the parsed plan — no S3 re-parsing is needed. - When the template is eventually created, it should define named ranges for every cell the lambda writes to. This decouples layout from code and means template redesigns require no code changes.
- The
openpyxllibrary must be added toapplications/audit-generator/handler/requirements.txt.