"""The message contract between the HubSpot scraper and the abri lambda. One message per scrape, naming every Abri flow that fired plus the deal fields those flows need. A message missing a field its flow needs fails validation — the resulting failed task / DLQ entry is the agreed visibility surface. """ from datetime import date from typing import Dict, List, Literal, Optional, Tuple from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] # The deal fields each flow reads; hubspot_deal_id is always required. # abandon_job requires no single field: it dates the cancel to whichever of the # confirmed survey date or last submission date is present, so requiring either # alone would reject valid messages; the orchestrator fails the flow if a deal # carries neither. Its outcome is optional too, only feeding the reason seam. _FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = { "amend_job": ("confirmed_survey_date", "third_party_surveyor_identifier"), "log_job": ( "place_ref", "deal_name", "confirmed_survey_date", "third_party_surveyor_identifier", ), "sync_tenant_data": ("place_ref",), "abandon_job": (), } class AbriTriggerRequest(BaseModel): model_config = ConfigDict(extra="ignore") hubspot_deal_id: str flows: List[AbriFlow] = Field(min_length=1) place_ref: Optional[str] = None deal_name: Optional[str] = None confirmed_survey_date: Optional[date] = None confirmed_survey_time: Optional[str] = None last_submission_date: Optional[date] = None outcome: Optional[str] = None # OpenHousing's bookable resource for the survey; required by the log and # amend flows. Empty string is also rejected by Abri, so not allowed here third_party_surveyor_identifier: Optional[str] = None # The Job Number OpenHousing returns on a successful log. Present means the # job exists (amend/abandon); absent means it was never logged (or the # reference was lost). Passed through, not required by any flow. A blank # value carries no Job Number, so it is normalised to missing. client_booking_reference: Optional[str] = None @field_validator("third_party_surveyor_identifier", "client_booking_reference") @classmethod def _blank_is_missing(cls, value: Optional[str]) -> Optional[str]: if value is not None and not value.strip(): return None return value @model_validator(mode="after") def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest": missing = [ f"flow {flow} requires {field}" for flow in self.flows for field in _FIELDS_BY_FLOW[flow] if getattr(self, field) is None ] if missing: raise ValueError("; ".join(missing)) return self