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
+163
View File
@@ -102,6 +102,16 @@ class Contact(Base, TimestampMixin):
cascade="all, delete-orphan",
order_by="ContactChannelRule.created_at",
)
quality_decisions: Mapped[list["ContactPointQualityDecision"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactPointQualityDecision.created_at",
)
field_provenance: Mapped[list["ContactFieldProvenance"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactFieldProvenance.created_at",
)
class ContactEmail(Base, TimestampMixin):
@@ -115,6 +125,9 @@ class ContactEmail(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
original_email: Mapped[str] = mapped_column(String(320), nullable=False, default="")
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -130,6 +143,9 @@ class ContactPhone(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80))
phone: Mapped[str] = mapped_column(String(100), nullable=False)
original_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="")
normalized_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -148,6 +164,9 @@ class ContactPostalAddress(Base, TimestampMixin):
locality: Mapped[str | None] = mapped_column(String(255))
region: Mapped[str | None] = mapped_column(String(255))
country: Mapped[str | None] = mapped_column(String(255))
original_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
normalized_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -221,6 +240,146 @@ class ContactPointSnapshot(Base, TimestampMixin):
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactPointQualityDecision(Base, TimestampMixin):
__tablename__ = "addresses_contact_point_quality_decisions"
__table_args__ = (
Index(
"ix_addresses_quality_current",
"tenant_id",
"contact_id",
"channel",
"contact_point_id",
"effective_until",
),
Index("ix_addresses_quality_state", "tenant_id", "state", "effective_until"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="quality_decisions")
class ContactMergeRecord(Base, TimestampMixin):
__tablename__ = "addresses_contact_merge_records"
__table_args__ = (
Index("ix_addresses_merge_winner", "tenant_id", "winner_contact_id", "created_at"),
Index("ix_addresses_merge_status", "tenant_id", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
address_book_id: Mapped[str] = mapped_column(
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
winner_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
loser_contact_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
survivorship: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
before_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
after_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
before_hash: Mapped[str] = mapped_column(String(64), nullable=False)
after_hash: Mapped[str] = mapped_column(String(64), nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recovered_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
recovery_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
recovery_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactRedirect(Base, TimestampMixin):
__tablename__ = "addresses_contact_redirects"
__table_args__ = (
Index(
"uq_addresses_contact_redirects_active_source",
"tenant_id",
"source_contact_id",
unique=True,
sqlite_where=text("ended_at IS NULL"),
postgresql_where=text("ended_at IS NULL"),
),
Index("ix_addresses_contact_redirects_target", "tenant_id", "target_contact_id", "ended_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
source_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
merge_record_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class ContactFieldProvenance(Base, TimestampMixin):
__tablename__ = "addresses_contact_field_provenance"
__table_args__ = (
Index("ix_addresses_field_provenance_contact", "contact_id", "field_path", "created_at"),
Index("ix_addresses_field_provenance_selected", "tenant_id", "contact_id", "selected"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
field_path: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
value: Mapped[Any] = mapped_column(JSON, nullable=True)
source_kind: Mapped[str] = mapped_column(String(40), nullable=False)
source_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
precedence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
selected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="inherit")
merge_record_id: Mapped[str | None] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="field_provenance")
class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists"
__table_args__ = (
@@ -401,7 +560,11 @@ __all__ = [
"Contact",
"ContactEmail",
"ContactPhone",
"ContactFieldProvenance",
"ContactMergeRecord",
"ContactPointQualityDecision",
"ContactPointSnapshot",
"ContactPostalAddress",
"ContactRedirect",
"new_uuid",
]