from dataclasses import dataclass from typing import Dict, List, Optional, Tuple, Union from domain.abri.models import AbriRequestRejected, PlaceRef, Tenant from etl.hubspot.deal_contacts_client import HubspotDealContactsClient from etl.hubspot.errors import HubspotRequestError from infrastructure.abri.abri_client import AbriClient 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 ) class TenantDataSyncOrchestrator: """Syncs an Abri tenancy's signatories to a HubSpot deal. Tenant data flows straight from Abri to HubSpot; nothing is persisted in Domna's database or logs. """ def __init__( self, abri_client: AbriClient, hubspot: HubspotDealContactsClient, ) -> None: self._abri = abri_client self._hubspot = hubspot def run(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._hubspot.create_contact( _contact_properties(tenant) ) contact_ids.append(contact_id) self._hubspot.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 ), )