Model/orchestration/abri_tenant_data_sync_orchestrator.py
2026-07-06 14:05:42 +00:00

136 lines
4.8 KiB
Python

from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, Union
from domain.abri.models import AbriRequestRejected, PlaceRef, Tenant
from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
from infrastructure.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 AbriTenantDataSyncOrchestrator:
"""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
),
)