12 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 lambda to follow the
@subtask_handler()pattern, so that it integrates with the task orchestration system and benefits from standard error handling and observability.
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
d1_ventilation_template.xlsxis bundled with the lambda atapplications/audit-generator/d1_ventilation_template.xlsxand loaded from the deployment package viaimportlib.resourcesor a path relative to the handler file. No S3 round-trip for the template. - The template is loaded with
openpyxl.load_workbook(path)(defaultdata_only=Falseto preserve formulas), populated, and serialised to bytes viaBytesIOfor upload. - Cell targeting uses fixed column letters (see Spreadsheet Layout below). Named ranges are not defined in the template.
- The template has formulas in columns J (
=H*I), N (=J*M), S (=Q*R), and Y (=W*X) — the lambda does not write to these cells; they are calculated by Excel/Sheets when the file is opened. - The template has 50 data rows (rows 6–55), extended programmatically. The footer merge sits at A56:Z56; legend rows at 57–60.
- 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"
-
DDD migration of
UploadedFile: The existingbackend/app/db/models/uploaded_file.py(SQLAlchemyBase) is replaced byinfrastructure/postgres/uploaded_file_table.py(SQLModel).FileTypeEnum,FileSourceEnum, andUploadedFileall move there. The class nameUploadedFileis kept (noModelsuffix — there is no domain counterpart). All seven consumers update their import path;backend/app/db/models/uploaded_file.pyis deleted. BecauseUploadedFileis now registered onSQLModel.metadata, the sharedtests/conftest.pydb_enginefixture must emitCREATE TYPE IF NOT EXISTSforfile_typeandfile_sourcevia raw SQL before callingSQLModel.metadata.create_all(engine)— otherwise the table creation fails for all integration tests. The dedicated per-test conftest approach (Question 6) is therefore superseded. -
New
UploadedFileRepository: A new repository (UploadedFilePostgresRepository) is introduced with aget_latest_by_hubspot_deal_id(hubspot_deal_id: str, file_type: FileTypeEnum) -> Optional[UploadedFile]method. Queriesuploaded_filesfiltered byhubspot_deal_idandfile_type, ordered bys3_upload_timestamp DESC, returning the most recent row. -
Session management: A dedicated
AuditGeneratorUnitOfWorkcontext manager (standalone — does not inherit fromPostgresUnitOfWorkorUnitOfWork) holdsuploaded_file: UploadedFilePostgresRepositoryandmagic_plan: MagicPlanPostgresRepository, both bound to the same session. Opens the session on__enter__, rolls back and closes on__exit__, exposescommit(). The handler holds a module-scoped engine (reused across warm Lambda invocations) and passes asession_factorycallable toAuditGeneratorUnitOfWork— the session is created fresh per invocation and never long-lived. -
Idempotency: No duplicate guard.
uploaded_filesis append-only — the lambda always inserts a new row; rows are never updated or deleted. The S3 file is always overwritten at the fixed key. The UI and any future queries treat the most recent row bys3_upload_timestampas authoritative. -
Environment variables:
S3_BUCKET_NAME(shared convention)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
S3ClientwithMagicMock(spec=S3Client). Mock theAuditGeneratorUnitOfWorkfactory: the factory returns a mock UoW whose__enter__returns itself and whose.uploaded_fileand.magic_planattributes are mock repos. - Test happy path: correct S3 key used for output upload;
uploaded_filesinsert called with correctfile_typeandfile_source;uow.commit()called. - Test error path: raises with appropriate message when
uploaded_file_repo.get_latest_by_hubspot_deal_idreturnsNone. - Test error path: raises with appropriate message when
magic_plan_repo.get_plan_by_uploaded_file_idreturnsNone.
Repository tests (tests/repositories/uploaded_file/test_uploaded_file_postgres_repository.py):
- Integration tests using the shared
db_enginefixture. The fixture already callsSQLModel.metadata.create_all(engine); after the DDD migrationUploadedFileis inSQLModel.metadata, so no dedicated conftest is needed. The sharedtests/conftest.pymust emitCREATE TYPE IF NOT EXISTSforfile_typeandfile_sourcebeforecreate_all. - Test that
get_latest_by_hubspot_deal_idreturns the most recent row bys3_upload_timestampwhen multiple rows with the samefile_typeexist. - Test that it returns
Nonewhen no matching row exists. - Test that it filters correctly by
file_type(a row with a differentfile_typeis not returned).
Out of Scope
- The SQS trigger — the UI button that sends the SQS message lives in a separate repo.
- Any ventilation calculation or compliance logic — the spreadsheet is populated with raw plan data only.
Spreadsheet Layout
Sheet name: D1 Ventilation. Data starts at row 6. The three series run in parallel columns — each row may contain room data, window data, and door data independently; the longest series determines the last row used.
| Column | Content | Source |
|---|---|---|
| B | Room name | Room.name |
| D | Room area (m²) | Room.area_m2 |
| G | Window location (room name) | Room.name (parent room) |
| H | Window width (m) | Window.width_m |
| I | Window height (m) | Window.height_m |
| J | Window area (m²) | formula =H*I — do not write |
| K | Opening type | WindowVentilation.opening_type |
| L | Number of openings | WindowVentilation.num_openings |
| M | % of window (decimal) | WindowVentilation.pct_openable / 100 |
| N | Total opening area (m²) | formula =J*M — do not write |
| O | Blocked | leave blank (visual check by auditor) |
| P | Pictured | leave blank (visual check by auditor) |
| Q | Trickle vent effective area per vent (mm²) | WindowVentilation.trickle_vent_area_mm2 |
| R | Number of trickle vents | WindowVentilation.num_trickle_vents |
| S | Total trickle vent area (mm²) | formula =Q*R — do not write |
| V | Door location (room name) | Room.name (parent room) |
| W | Door width (mm) | Door.width_mm |
| X | Door undercut (mm) | DoorVentilation.undercut_mm |
| Y | Door area (mm²) | formula =W*X — do not write |
Internal doors appear once per room they connect (typically twice). WindowVentilation and DoorVentilation fields are Optional; write 0 when None so formula cells (J, N, S, Y) do not produce #VALUE! errors.
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 is the correct entry point for fetching the parsed plan — no S3 re-parsing is needed. - The
openpyxllibrary must be added toapplications/audit-generator/handler/requirements.txt. - The template (
d1_ventilation_template.xlsx) has 50 data rows (rows 6–55) with formulas in columns J, N, S, Y. If a property exceeds 50 windows, rooms, or doors the lambda should raise a clear error rather than silently truncating.