from dataclasses import dataclass from datetime import date from typing import Dict, List, Optional, Protocol, Tuple, Union from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( AbriRequestRejected, AmendJobRequest, AppointmentAmended, LogJobRequest, PlaceRef, Tenant, ) from domain.abri.slots import slot_for_confirmed_time from infrastructure.abri.abri_client import AbriClient from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient from infrastructure.hubspot.errors import HubspotRequestError # The HubSpot deal property that carries the OpenHousing job number; the # property ID is fixed externally, the domain and database call it job_no. JOB_NO_DEAL_PROPERTY = "client_booking_reference" def _vulnerability_description(tenant: Tenant) -> Optional[str]: """The tenant's distinct vulnerabilities, verbatim and newline-joined. Case-insensitively distinct within the tenant, keeping the first-seen casing and document order. """ distinct: List[str] = [] seen: set[str] = set() for vulnerability in tenant.vulnerabilities: if vulnerability.lower() in seen: continue seen.add(vulnerability.lower()) distinct.append(vulnerability) return "\n".join(distinct) if distinct else None def _contact_properties(tenant: Tenant) -> Dict[str, str]: candidates: Dict[str, Optional[str]] = { "firstname": tenant.forenames, "lastname": tenant.surname, "mobilephone": tenant.mobile, "phone": tenant.telephone, "secondary_phone_number": tenant.secondary_number, "vulnerability_description": _vulnerability_description(tenant), } properties = {name: value for name, value in candidates.items() if value} # Always written, so "not vulnerable" is distinguishable from "not synced". properties["is_vulnerable"] = "true" if tenant.vulnerabilities else "false" return properties @dataclass(frozen=True) class TenantDataSyncSummary: """PII-free record of what a sync run did: safe to log and persist.""" tenancy_reference: str contact_ids: Tuple[str, ...] vulnerable_contact_count: int TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected] class TenantDataSyncError(Exception): """A sync that stopped at the first failed HubSpot write. Carries a PII-free progress report so the operator knows the deal's half-state before cleaning up and re-running; contacts created but not associated are orphaned in HubSpot and need manual cleanup. """ def __init__( self, message: str, deal_id: str, tenant_index: int, contact_ids_created: Tuple[str, ...], contact_ids_associated: Tuple[str, ...], ) -> None: self.deal_id = deal_id self.tenant_index = tenant_index self.contact_ids_created = contact_ids_created self.contact_ids_associated = contact_ids_associated super().__init__( f"{message} (deal: {deal_id}, tenant index: {tenant_index}, " f"contacts created: {list(contact_ids_created)}, " f"associated: {list(contact_ids_associated)})" ) @property def orphaned_contact_ids(self) -> Tuple[str, ...]: return tuple( contact_id for contact_id in self.contact_ids_created if contact_id not in self.contact_ids_associated ) @dataclass(frozen=True) class ConfirmedSurveyBooking: """The deal fields a confirmed survey booking contributes to a LogJob.""" deal_id: str place_ref: PlaceRef deal_name: str confirmed_survey_date: date confirmed_survey_time: Optional[str] @dataclass(frozen=True) class LogJobSummary: """PII-free record of a logged job: safe to log and persist.""" deal_id: str job_no: str LogJobOrchestrationResult = Union[LogJobSummary, AbriRequestRejected] class DealDatabaseGateway(Protocol): def record_job_no(self, deal_id: str, job_no: str) -> None: ... def job_no_for_deal(self, deal_id: str) -> Optional[str]: ... @dataclass(frozen=True) class AppointmentChange: """The deal fields a changed survey booking contributes to an AmendJob.""" deal_id: str confirmed_survey_date: date confirmed_survey_time: Optional[str] AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] class JobNoNotYetRecordedError(Exception): """An amendment arrived before the deal's job_no landed in the database. Retriable: the message should be redelivered until the log flow's write-back lands (or it dead-letters), so a rapid log-then-amend sequence eventually converges. """ def __init__(self, deal_id: str) -> None: self.deal_id = deal_id super().__init__( f"no job_no recorded yet for deal {deal_id}; cannot amend its appointment" ) class LogJobWriteBackError(Exception): """A job logged in OpenHousing whose job_no could not be written back. Carries the orphaned job_no so an operator can paste it into HubSpot manually; the run must never retry log_job, which would log a duplicate job in OpenHousing. """ def __init__(self, message: str, deal_id: str, job_no: str) -> None: self.deal_id = deal_id self.job_no = job_no super().__init__(f"{message} (deal: {deal_id}, orphaned job_no: {job_no})") def client_ref_for_deal(deal_id: str) -> str: """The client_ref sent to OpenHousing: the HubSpot deal ID, for now. UNCONFIRMED with Abri: whether client_ref must be unique per job (a deal re-logging after a cancellation would repeat the bare deal ID) is an open question on issue #1478. Swap the derivation here once Abri answers. """ return deal_id class AbriOrchestrator: """Runs the Abri OpenHousing flows for a HubSpot deal, one method each. Tenant data flows straight from Abri to HubSpot; nothing tenant-related is persisted in Domna's database or logs. For job logging, HubSpot is written first (it is the source of truth); the database second, so a failed database write self-heals on the next scrape. """ def __init__( self, abri_client: AbriClient, deal_contacts: HubspotDealContactsClient, deal_properties: HubspotDealPropertiesClient, deal_database: DealDatabaseGateway, ) -> None: self._abri = abri_client self._deal_contacts = deal_contacts self._deal_properties = deal_properties self._deal_database = deal_database def sync_tenant_data( self, place_ref: PlaceRef, deal_id: str ) -> TenantDataSyncResult: tenancy = self._abri.get_tenant_data(place_ref) if isinstance(tenancy, AbriRequestRejected): return tenancy contact_ids: List[str] = [] associated_ids: List[str] = [] for tenant_index, tenant in enumerate(tenancy.tenants): try: contact_id = self._deal_contacts.create_contact( _contact_properties(tenant) ) contact_ids.append(contact_id) self._deal_contacts.associate_contact_to_deal( deal_id=deal_id, contact_id=contact_id ) associated_ids.append(contact_id) except HubspotRequestError as error: # Fail fast at the first failed write; `error` is already # scrubbed, so chaining it keeps tracebacks PII-free. raise TenantDataSyncError( message=str(error), deal_id=deal_id, tenant_index=tenant_index, contact_ids_created=tuple(contact_ids), contact_ids_associated=tuple(associated_ids), ) from error return TenantDataSyncSummary( tenancy_reference=tenancy.tenancy_reference, contact_ids=tuple(contact_ids), vulnerable_contact_count=sum( 1 for tenant in tenancy.tenants if tenant.vulnerabilities ), ) def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult: job_no = self._deal_database.job_no_for_deal(change.deal_id) if job_no is None: raise JobNoNotYetRecordedError(deal_id=change.deal_id) return self._abri.amend_job( AmendJobRequest( job_no=job_no, appointment_date=change.confirmed_survey_date, appointment_time=slot_for_confirmed_time( change.confirmed_survey_time ), ) ) def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: descriptions = build_job_descriptions(booking.deal_name) outcome = self._abri.log_job( LogJobRequest( client_ref=client_ref_for_deal(booking.deal_id), place_ref=booking.place_ref, appointment_date=booking.confirmed_survey_date, appointment_time=slot_for_confirmed_time( booking.confirmed_survey_time ), short_description=descriptions.short_description, long_description=descriptions.long_description, ) ) if isinstance(outcome, AbriRequestRejected): return outcome try: self._deal_properties.write_deal_property( deal_id=booking.deal_id, property_name=JOB_NO_DEAL_PROPERTY, value=outcome.job_no, ) except HubspotRequestError as error: # The job now exists in OpenHousing; never re-run log_job (it # would log a duplicate). `error` is already scrubbed, so # chaining it keeps tracebacks free of the response body. raise LogJobWriteBackError( message=str(error), deal_id=booking.deal_id, job_no=outcome.job_no, ) from error self._deal_database.record_job_no( deal_id=booking.deal_id, job_no=outcome.job_no ) return LogJobSummary(deal_id=booking.deal_id, job_no=outcome.job_no)