Implement address quality and reversible contact merges

This commit is contained in:
2026-08-02 07:03:27 +02:00
parent 2e78b9ae50
commit 19e9096572
15 changed files with 4369 additions and 31 deletions
+185
View File
@@ -15,6 +15,13 @@ AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "ma
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
AddressContactPointChannel = Literal[
"email",
"phone",
"postal",
"internal_mail",
"portal",
]
AddressChannelDecision = Literal[
"allowed",
"opted_in",
@@ -38,6 +45,13 @@ AddressDistributionOutcome = Literal[
]
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
AddressPostalFormat = Literal["domestic", "international"]
ContactPointQualityState = Literal[
"valid",
"invalid",
"returned",
"stale",
"undeliverable",
]
class ContactEmailPayload(BaseModel):
@@ -186,12 +200,75 @@ class ContactUpdateRequest(BaseModel):
provenance: dict[str, Any] | None = None
class ContactFieldProvenanceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
contact_id: str
field_path: str
value: Any = None
source_kind: str
source_ref: str | None = None
source_revision: str | None = None
precedence: int
selected: bool
reason_code: str
explanation: str | None = None
visibility: str
merge_record_id: str | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
class ContactPointQualityDecisionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressContactPointChannel
contact_point_id: str | None = Field(default=None, max_length=36)
state: ContactPointQualityState
reason_code: str | None = Field(default=None, max_length=120)
reason: str | None = None
evidence_ref: str | None = Field(default=None, max_length=1000)
effective_from: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactPointQualityDecisionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
evidence_ref: str | None = None
effective_from: datetime
effective_until: datetime | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
updated_at: datetime
class ContactPointQualityDecisionListResponse(BaseModel):
decisions: list[ContactPointQualityDecisionResponse] = Field(default_factory=list)
class ContactEmailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
label: str | None = None
email: str
original_email: str = ""
normalized_email: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -201,6 +278,11 @@ class ContactPhoneResponse(BaseModel):
id: str
label: str | None = None
phone: str
original_phone: str = ""
normalized_phone: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -214,6 +296,11 @@ class ContactPostalAddressResponse(BaseModel):
locality: str | None = None
region: str | None = None
country: str | None = None
original_value: dict[str, Any] = Field(default_factory=dict)
normalized_value: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -238,6 +325,7 @@ class ContactResponse(BaseModel):
emails: list[ContactEmailResponse]
phones: list[ContactPhoneResponse]
postal_addresses: list[ContactPostalAddressResponse]
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
deleted_at: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -251,6 +339,103 @@ class ContactListResponse(BaseModel):
has_more: bool
class ContactDuplicateFeatureResponse(BaseModel):
code: str
label: str
weight: int
value: str
class ContactDuplicateSuggestionResponse(BaseModel):
left: ContactResponse
right: ContactResponse
score: int
confidence: Literal["possible", "likely", "strong"]
features: list[ContactDuplicateFeatureResponse]
class ContactDuplicateSuggestionListResponse(BaseModel):
suggestions: list[ContactDuplicateSuggestionResponse] = Field(default_factory=list)
scanned_contacts: int
candidate_pairs: int
truncated: bool
class ContactMergeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
winner_contact_id: str = Field(max_length=36)
duplicate_contact_ids: list[str] = Field(min_length=1, max_length=20)
reason: str = Field(min_length=3)
field_sources: dict[str, str] = Field(default_factory=dict)
contact_point_strategy: Literal["union", "winner_only"] = "union"
source_precedence: list[str] = Field(default_factory=list, max_length=20)
class ContactMergeRecoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str = Field(min_length=3)
expected_after_hash: str = Field(min_length=64, max_length=64)
class ContactMergeRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
address_book_id: str
winner_contact_id: str
loser_contact_ids: list[str]
status: str
reason: str
survivorship: dict[str, Any]
decisions: list[dict[str, Any]]
before_hash: str
after_hash: str
created_by_account_id: str | None = None
recovered_at: datetime | None = None
recovered_by_account_id: str | None = None
recovery_action: str | None = None
recovery_reason: str | None = None
provenance: dict[str, Any]
created_at: datetime
updated_at: datetime
class ContactMergeRecordListResponse(BaseModel):
merges: list[ContactMergeRecordResponse] = Field(default_factory=list)
class ContactRedirectResponse(BaseModel):
requested_contact_id: str
resolved_contact_id: str
redirected: bool
redirect_chain: list[str] = Field(default_factory=list)
merge_record_ids: list[str] = Field(default_factory=list)
class AddressQualityCorrectionResponse(BaseModel):
contact_id: str
display_name: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
effective_from: datetime
class AddressQualitySummaryResponse(BaseModel):
contact_count: int
contact_point_count: int
quality_counts: dict[str, int] = Field(default_factory=dict)
duplicate_suggestion_count: int
correction_count: int
corrections: list[AddressQualityCorrectionResponse] = Field(default_factory=list)
truncated: bool = False
class ContactChannelRuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")