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
+28 -2
View File
@@ -42,6 +42,16 @@ inspection UI are implemented. The conflict review UI compares stored local and
remote field payloads, can apply a stored remote vCard payload, and supports
manual per-field local/remote merge choices.
Address quality and duplicate handling are implemented as an operator workflow.
Contact points retain both their original and normalized values, field-level
provenance is append-only, and current quality states can mark a point valid,
invalid, returned, stale, or undeliverable. Those states flow into recipient
resolution with stable reason codes. The quality dialog shows bounded,
explainable duplicate suggestions and a correction queue. Merges record explicit
survivorship decisions, repair address-list memberships, preserve redirects for
stored contact references, and can be undone or split while the post-merge
evidence hash still matches.
API-managed CardDAV credentials are encrypted inside the source record. Source
deletion physically removes that credential material and records a non-secret
audit event in the same database transaction; destructive module retirement
@@ -72,9 +82,9 @@ It must not own:
effective function assignments
- operational distribution lists/`Verteiler` with mixed recipient types
## First Capabilities
## Capabilities
The module exposes four core-mediated capabilities:
The module exposes core-mediated capabilities for:
- `addresses.lookup`: read-only contact/recipient lookup for autocomplete.
- `addresses.recipient_source`: immutable recipient snapshots for campaign,
@@ -84,6 +94,10 @@ The module exposes four core-mediated capabilities:
- `addresses.contact_point_resolution`: purpose-aware, channel-neutral
resolution and immutable snapshots for email, postal, internal-mail, and
portal targets.
- `addresses.people_search`: privacy-aware contact candidates for shared people
pickers.
- `distribution.recipient_channel_facts`: current channel, governance, and
quality facts for distribution and Policy consumers.
`addresses.recipient_source` returns:
@@ -131,8 +145,20 @@ The corresponding HTTP API is available below `/api/v1/addresses`:
- `POST /contact-point-snapshots`
- `GET /contact-point-snapshots/{snapshot_id}`
Quality, provenance, and reversible merge operations are available through:
- `GET /address-books/{book_id}/quality-summary`
- `GET /address-books/{book_id}/duplicate-suggestions`
- `GET|POST /contacts/{contact_id}/quality-decisions`
- `GET /contacts/{contact_id}/provenance`
- `GET /contacts/{contact_id}/redirect`
- `GET|POST /contact-merges`
- `POST /contact-merges/{merge_id}/undo`
- `POST /contact-merges/{merge_id}/split`
## Design Documents
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
- [Address quality and reversible merges](docs/QUALITY_AND_MERGE.md)
- [AdreMa capability assessment and Distribution Lists roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/src/branch/main/docs/ADREMA_CAPABILITY_ASSESSMENT.md)
+28 -2
View File
@@ -70,7 +70,8 @@ representation for import/export and conflict handling.
The local baseline implements scoped address books, contacts, normalized
email/phone/postal-address tables, tags, source kind/reference fields,
first-class source payload/revision fields, and provenance JSON. Imported
first-class source payload/revision fields, preserved original contact-point
values, and append-only field provenance. Imported
vCards preserve raw source payload and revision metadata for audit/debugging.
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
first-class backend tables and API resources. Connector-specific diffing,
@@ -175,6 +176,32 @@ module retirement audits all remaining owned credential material before table
removal. An unowned legacy reference is detached rather than passed to an
external secret provider.
## Quality, Deduplication, And Recovery
Quality is evidence about a concrete contact point, separate from communication
consent or Policy. Effective decisions use one of `valid`, `invalid`,
`returned`, `stale`, or `undeliverable`, retain reason/evidence references, and
end an overlapping prior decision rather than rewriting history. Recipient
capabilities project the current decision into a stable status and reason code;
consumers can exclude invalid points or explicitly handle stale points without
copying Addresses rules.
Duplicate suggestions are bounded to 500 scanned contacts and 100 returned
pairs. Every score is composed from visible exact-match features such as a
normalized email, phone, postal address, or name/organization combination. A
suggestion does not mutate data.
A merge is an explicit, transactional decision. The caller selects a surviving
contact, scalar-field sources, source precedence, and either union or
survivor-only contact-point handling. The merge records before/after evidence
and hashes, field/contact-point decisions, copied quality/governance evidence,
and stable loser-to-winner redirects. Address-list entries are repointed in the
same transaction. Undo and split restore the recorded contacts and memberships
only when the current evidence still matches the post-merge hash; later edits
must be reconciled first. Core change-sequence evidence is always written. Core
audit entries are written by HTTP mutation routes without requiring the
optional Audit module.
## Connector Direction
Implement connectors in this order:
@@ -218,7 +245,6 @@ those provider-owned facts.
The following are valuable but not required for the first functional milestone:
- automatic deduplication and merge suggestions
- two-way sync conflict UI
- Microsoft/Google connectors
- richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
+16 -10
View File
@@ -219,17 +219,19 @@ Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`,
Tasks:
- LDAP/Active Directory read-only directory connector
- Exchange/Microsoft 365 contacts connector
- Google Contacts connector
- CSV/XLSX/LDIF import mapping profiles
- classical address-list UI; reusable static/dynamic operational segments move
- [ ] LDAP/Active Directory read-only directory connector
- [ ] Exchange/Microsoft 365 contacts connector
- [ ] Google Contacts connector
- [ ] CSV/XLSX/LDIF import mapping profiles
- [x] classical address-list UI; reusable static/dynamic operational segments move
to `govoplan-dist-lists`
- operational distribution lists move to `govoplan-dist-lists`
- consent, legal-basis, suppression, and communication preferences
- deduplication and merge workflow
- address quality checks and normalization
- richer vCard `KIND`/`RELATED` round-trip and stable links to IDM/Organizations
- [x] operational distribution lists move to `govoplan-dist-lists`
- [x] consent, legal-basis, suppression, and communication preferences
- [x] bounded, explainable deduplication and reversible merge/split workflow
- [x] contact-point quality states, normalization, original-value preservation,
field provenance, and correction dashboard
- [x] stable redirect resolution for merged contact references
- [ ] richer vCard `KIND`/`RELATED` round-trip and stable links to IDM/Organizations
Exit criteria:
@@ -237,6 +239,10 @@ Exit criteria:
- users can understand where data came from and whether they may edit it
- downstream modules can safely use contacts without owning them
Issues #9 and #10 are implemented. Issue #8 tracks the connector portfolio and
is split into independently deliverable connector/import follow-ups rather than
keeping one cross-protocol implementation ticket open.
## First Implementation Recommendation
Start with Milestone 1 and enough of Milestone 2 to define the data model
+57
View File
@@ -0,0 +1,57 @@
# Address Quality And Reversible Merges
## Operator Workflow
Open the shield action for a selected address book to review its quality. The
dialog shows:
- the number of contacts and contact points in the bounded scan
- current invalid, returned, stale, and undeliverable contact points
- explainable duplicate suggestions with their score inputs
- active and recovered merge records
Each contact point also has a `Quality` action in the contact detail. Recording
a new state ends an overlapping current state and retains both entries in
history. Use a stable reason code and an evidence reference when the state came
from delivery, import, or correction evidence.
`valid` makes the point normally usable. `invalid`, `returned`, and
`undeliverable` make it invalid for recipient resolution. `stale` remains a
distinct status so a downstream workflow can warn, request confirmation, or
block according to Policy. A later `valid` decision is a correction; it does
not delete the earlier evidence.
## Duplicate Review
Suggestions do not merge automatically. The score is the bounded sum of named
exact-match features. The operator chooses the surviving contact and whether to
combine unique contact points or retain only the survivor's points. The API can
additionally select the source contact for each scalar field and rank source
kinds.
A successful merge:
- archives each duplicate and redirects its stable contact ID to the survivor
- records scalar and contact-point survivorship decisions
- carries field and contact-point source provenance forward
- copies applicable quality and communication-governance evidence
- repoints address-list entries to the survivor and mapped contact point
- stores deterministic before/after evidence hashes
- emits core change-sequence and audit evidence
The merge history offers `Undo` and `Split`. Both restore the exact recorded
pre-merge contacts and list memberships. Recovery is deliberately rejected when
the contact or membership evidence changed after the merge. Reconcile those
later edits before retrying; the system does not silently discard them.
## Consumer Contract
Consumers resolve live contacts through `addresses.contact_point_resolution` or
`distribution.recipient_channel_facts`. They receive quality status, stable
reason codes, evidence provenance, and the current source revision. Consumers
must not read Addresses tables or recreate quality rules. A workflow requiring
historical proof freezes a contact-point snapshot before delivery.
The duplicate and quality endpoints are bounded. `truncated=true` means the
operator should narrow the source or run a staged API review; it does not mean
that the unreturned contacts were found clean.
+110 -5
View File
@@ -41,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPointQualityDecision,
ContactPointSnapshot,
ContactPostalAddress,
)
@@ -48,6 +49,7 @@ from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
current_contact_quality,
get_visible_address_book,
get_visible_address_list,
get_visible_contact,
@@ -55,6 +57,7 @@ from govoplan_addresses.backend.service import (
list_address_list_entries,
list_address_lists,
list_contacts,
resolve_contact_redirect,
)
@@ -488,6 +491,7 @@ class AddressesChannelFactsCapability:
and _aware_datetime(rule.effective_until) <= effective_at
]
revision, fingerprint = _contact_channel_revision(contact)
quality = current_contact_quality(contact, effective_at=effective_at)
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
@@ -529,6 +533,20 @@ class AddressesChannelFactsCapability:
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
)
)
quality_decision = quality.get((channel, point_id)) or quality.get(
(channel, None)
)
if quality_decision is not None and quality_decision.state != "valid":
status = (
"stale"
if quality_decision.state == "stale"
else "invalid"
)
reason_code = quality_decision.reason_code
explanation = quality_decision.reason or (
f"This {channel.replace('_', ' ')} contact point is marked "
f"{quality_decision.state}."
)
candidates.append(
DistributionChannelCandidate(
channel=channel,
@@ -551,6 +569,17 @@ class AddressesChannelFactsCapability:
"legal_basis": selected.legal_basis if selected is not None else None,
"evidence_ref": selected.evidence_ref if selected is not None else None,
"preference_rank": selected.preference_rank if selected is not None else None,
"quality_decision_id": (
quality_decision.id if quality_decision is not None else None
),
"quality_state": (
quality_decision.state if quality_decision is not None else "valid"
),
"quality_evidence_ref": (
quality_decision.evidence_ref
if quality_decision is not None
else None
),
},
)
)
@@ -1178,7 +1207,28 @@ def _address_book_contact_updated_at(session: Any, address_book_ids: list[str])
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows, *postal_rows, *rule_rows]:
quality_rows = (
session.query(
Contact.address_book_id,
func.max(ContactPointQualityDecision.updated_at),
)
.join(Contact, ContactPointQualityDecision.contact_id == Contact.id)
.filter(
Contact.address_book_id.in_(address_book_ids),
Contact.deleted_at.is_(None),
)
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [
*contact_rows,
*deletion_rows,
*email_rows,
*phone_rows,
*postal_rows,
*rule_rows,
*quality_rows,
]:
if updated_at is None:
continue
key = str(address_book_id)
@@ -1221,6 +1271,16 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
.group_by(AddressListEntry.address_list_id)
.all()
)
quality_rows = (
session.query(
AddressListEntry.address_list_id,
func.max(ContactPointQualityDecision.updated_at),
)
.join(ContactPointQualityDecision, AddressListEntry.contact_id == ContactPointQualityDecision.contact_id)
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
.group_by(AddressListEntry.address_list_id)
.all()
)
postal_rows = (
session.query(
AddressListEntry.address_list_id,
@@ -1241,6 +1301,7 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
*email_rows,
*postal_rows,
*rule_rows,
*quality_rows,
]:
if updated_at is None:
continue
@@ -1359,7 +1420,15 @@ def _channel_rule_explanation(rule: ContactChannelRule) -> str:
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
stamps = [contact.updated_at]
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
stamps.extend(
item.updated_at
for item in (
*contact.emails,
*contact.postal_addresses,
*contact.channel_rules,
*contact.quality_decisions,
)
)
revision = max(_aware_datetime(item) for item in stamps).isoformat()
payload = {
"contact_id": contact.id,
@@ -1397,6 +1466,20 @@ def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
}
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
],
"quality": [
{
"id": item.id,
"channel": item.channel,
"point": item.contact_point_id,
"state": item.state,
"reason_code": item.reason_code,
"evidence": item.evidence_ref,
"from": item.effective_from.isoformat(),
"until": item.effective_until.isoformat() if item.effective_until else None,
"updated": item.updated_at.isoformat(),
}
for item in sorted(contact.quality_decisions, key=lambda row: row.id)
],
}
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -1426,7 +1509,21 @@ def _contacts_for_subject(
try:
return [get_visible_contact(session, principal, str(direct_id))]
except AddressBookError:
return []
try:
resolution = resolve_contact_redirect(
session,
principal,
str(direct_id),
)
return [
get_visible_contact(
session,
principal,
resolution.resolved_contact_id,
)
]
except AddressBookError:
return []
explicit_ref = subject.metadata.get("source_ref")
source_refs = {
@@ -1445,16 +1542,24 @@ def _contacts_for_subject(
.filter(
Contact.source_ref.in_(source_refs),
or_(Contact.tenant_id == principal.tenant_id, Contact.tenant_id.is_(None)),
Contact.deleted_at.is_(None),
)
.order_by(Contact.id.asc())
.limit(3)
.all()
)
visible: list[Contact] = []
visible_ids: set[str] = set()
for row in rows:
try:
visible.append(get_visible_contact(session, principal, row.id))
resolution = resolve_contact_redirect(session, principal, row.id)
contact = get_visible_contact(
session,
principal,
resolution.resolved_contact_id,
)
if contact.id not in visible_ids:
visible.append(contact)
visible_ids.add(contact.id)
except AddressBookError:
continue
return visible
+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",
]
@@ -43,6 +43,10 @@ from govoplan_addresses.backend.provider_state import (
_addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
@@ -154,6 +158,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
AddressList,
AddressSyncSource,
Contact,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPointSnapshot,
)
@@ -161,6 +167,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
}
@@ -286,6 +294,10 @@ manifest = ModuleManifest(
addresses_models.AddressSyncSource,
addresses_models.AddressListEntry,
addresses_models.AddressList,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressBook,
addresses_models.Contact,
@@ -328,6 +340,25 @@ manifest = ModuleManifest(
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
),
DocumentationTopic(
id="addresses.quality-and-merge",
title="Contact quality, duplicates, and reversible merges",
summary="Review address quality and duplicate suggestions without losing source evidence.",
body=(
"Addresses preserves original and normalized contact-point values, records field-level provenance, "
"and projects invalid, returned, stale, or undeliverable states into recipient resolution with stable "
"reason codes. Duplicate suggestions are bounded and explain their matching features. An operator can "
"choose the surviving values, merge contact points, and later undo or split the merge while the recorded "
"post-merge evidence still matches. Contact redirects keep stored references resolvable, and address-list "
"memberships are repaired transactionally. Audit remains an optional integration; the Addresses change "
"sequence and merge evidence are always retained."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "dist_lists", "policy", "audit"),
order=32,
),
),
external_providers=(CARDDAV_PROVIDER,),
external_provider_state_providers=(
@@ -0,0 +1,281 @@
"""Add address quality, provenance, merge evidence, and redirects.
Revision ID: b4c6d7e8f9a0
Revises: a3b5c6d7e8f9
"""
from __future__ import annotations
import re
from alembic import op
import sqlalchemy as sa
revision = "b4c6d7e8f9a0"
down_revision = "a3b5c6d7e8f9"
branch_labels = None
depends_on = None
_JSON_OBJECT = sa.text("'{}'")
def upgrade() -> None:
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.add_column(sa.Column("original_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.add_column(sa.Column("original_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.add_column(sa.Column("original_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("normalized_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE addresses_contact_emails "
"SET original_email = email, normalized_email = lower(trim(email))"
)
)
phone_rows = bind.execute(
sa.text("SELECT id, phone FROM addresses_contact_phones")
).mappings().all()
for row in phone_rows:
bind.execute(
sa.text(
"UPDATE addresses_contact_phones "
"SET original_phone = :original, normalized_phone = :normalized "
"WHERE id = :id"
),
{
"id": row["id"],
"original": row["phone"],
"normalized": _normalized_phone(str(row["phone"] or "")),
},
)
postal = sa.table(
"addresses_contact_postal_addresses",
sa.column("id", sa.String()),
sa.column("label", sa.String()),
sa.column("street", sa.String()),
sa.column("postal_code", sa.String()),
sa.column("locality", sa.String()),
sa.column("region", sa.String()),
sa.column("country", sa.String()),
sa.column("original_value", sa.JSON()),
sa.column("normalized_value", sa.JSON()),
)
postal_rows = bind.execute(
sa.select(
postal.c.id,
postal.c.label,
postal.c.street,
postal.c.postal_code,
postal.c.locality,
postal.c.region,
postal.c.country,
)
).mappings().all()
for row in postal_rows:
original = {
key: row[key]
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
normalized = {
key: _normalized_text(row[key])
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
bind.execute(
postal.update()
.where(postal.c.id == row["id"])
.values(original_value=original, normalized_value=normalized)
)
op.create_index(
"ix_addresses_contact_emails_normalized_email",
"addresses_contact_emails",
["normalized_email"],
)
op.create_index(
"ix_addresses_contact_phones_normalized_phone",
"addresses_contact_phones",
["normalized_phone"],
)
op.create_table(
"addresses_contact_point_quality_decisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("channel", sa.String(length=30), nullable=False),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_quality_decisions_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_quality_decisions_contact_id", ["contact_id"]),
("ix_addresses_contact_point_quality_decisions_channel", ["channel"]),
("ix_addresses_contact_point_quality_decisions_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_point_quality_decisions_state", ["state"]),
("ix_addresses_contact_point_quality_decisions_effective_from", ["effective_from"]),
("ix_addresses_contact_point_quality_decisions_effective_until", ["effective_until"]),
("ix_addresses_contact_point_quality_decisions_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_quality_current", ["tenant_id", "contact_id", "channel", "contact_point_id", "effective_until"]),
("ix_addresses_quality_state", ["tenant_id", "state", "effective_until"]),
):
op.create_index(name, "addresses_contact_point_quality_decisions", columns)
op.create_table(
"addresses_contact_merge_records",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("address_book_id", sa.String(length=36), nullable=False),
sa.Column("winner_contact_id", sa.String(length=36), nullable=False),
sa.Column("loser_contact_ids", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("survivorship", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("decisions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("before_payload", sa.JSON(), nullable=False),
sa.Column("after_payload", sa.JSON(), nullable=False),
sa.Column("before_hash", sa.String(length=64), nullable=False),
sa.Column("after_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovery_action", sa.String(length=30), nullable=True),
sa.Column("recovery_reason", sa.Text(), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["address_book_id"], ["addresses_address_books.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["winner_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_merge_records_tenant_id", ["tenant_id"]),
("ix_addresses_contact_merge_records_address_book_id", ["address_book_id"]),
("ix_addresses_contact_merge_records_winner_contact_id", ["winner_contact_id"]),
("ix_addresses_contact_merge_records_status", ["status"]),
("ix_addresses_contact_merge_records_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_merge_winner", ["tenant_id", "winner_contact_id", "created_at"]),
("ix_addresses_merge_status", ["tenant_id", "status", "created_at"]),
):
op.create_index(name, "addresses_contact_merge_records", columns)
op.create_table(
"addresses_contact_redirects",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("source_contact_id", sa.String(length=36), nullable=False),
sa.Column("target_contact_id", sa.String(length=36), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["target_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_redirects_tenant_id", ["tenant_id"]),
("ix_addresses_contact_redirects_source_contact_id", ["source_contact_id"]),
("ix_addresses_contact_redirects_target_contact_id", ["target_contact_id"]),
("ix_addresses_contact_redirects_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_redirects_ended_at", ["ended_at"]),
("ix_addresses_contact_redirects_target", ["tenant_id", "target_contact_id", "ended_at"]),
):
op.create_index(name, "addresses_contact_redirects", columns)
op.create_index(
"uq_addresses_contact_redirects_active_source",
"addresses_contact_redirects",
["tenant_id", "source_contact_id"],
unique=True,
sqlite_where=sa.text("ended_at IS NULL"),
postgresql_where=sa.text("ended_at IS NULL"),
)
op.create_table(
"addresses_contact_field_provenance",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("field_path", sa.String(length=255), nullable=False),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("source_kind", sa.String(length=40), nullable=False),
sa.Column("source_ref", sa.String(length=1000), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("precedence", sa.Integer(), nullable=False),
sa.Column("selected", sa.Boolean(), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("visibility", sa.String(length=30), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_field_provenance_tenant_id", ["tenant_id"]),
("ix_addresses_contact_field_provenance_contact_id", ["contact_id"]),
("ix_addresses_contact_field_provenance_field_path", ["field_path"]),
("ix_addresses_contact_field_provenance_selected", ["selected"]),
("ix_addresses_contact_field_provenance_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_field_provenance_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_field_provenance_contact", ["contact_id", "field_path", "created_at"]),
("ix_addresses_field_provenance_selected", ["tenant_id", "contact_id", "selected"]),
):
op.create_index(name, "addresses_contact_field_provenance", columns)
def downgrade() -> None:
op.drop_table("addresses_contact_field_provenance")
op.drop_table("addresses_contact_redirects")
op.drop_table("addresses_contact_merge_records")
op.drop_table("addresses_contact_point_quality_decisions")
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.drop_column("provenance")
batch.drop_column("normalized_value")
batch.drop_column("original_value")
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.drop_index("ix_addresses_contact_phones_normalized_phone")
batch.drop_column("provenance")
batch.drop_column("normalized_phone")
batch.drop_column("original_phone")
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.drop_index("ix_addresses_contact_emails_normalized_email")
batch.drop_column("provenance")
batch.drop_column("normalized_email")
batch.drop_column("original_email")
def _normalized_text(value: object) -> str | None:
if value is None:
return None
normalized = " ".join(str(value).strip().casefold().split())
return normalized or None
def _normalized_phone(value: str) -> str:
prefix = "+" if value.strip().startswith("+") else ""
return prefix + re.sub(r"\D", "", value)
+566 -2
View File
@@ -25,6 +25,8 @@ from govoplan_addresses.backend.db.models import (
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPostalAddress,
)
from govoplan_addresses.backend.capabilities import (
@@ -74,6 +76,18 @@ from govoplan_addresses.backend.schemas import (
ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse,
ContactChannelRuleResponse,
ContactDuplicateFeatureResponse,
ContactDuplicateSuggestionListResponse,
ContactDuplicateSuggestionResponse,
ContactFieldProvenanceResponse,
ContactMergeRecordListResponse,
ContactMergeRecordResponse,
ContactMergeRecoveryRequest,
ContactMergeRequest,
ContactPointQualityDecisionCreateRequest,
ContactPointQualityDecisionListResponse,
ContactPointQualityDecisionResponse,
ContactRedirectResponse,
ContactPointResolveRequest,
ContactPointResolutionResponse,
ContactPointSnapshotResponse,
@@ -82,6 +96,8 @@ from govoplan_addresses.backend.schemas import (
ContactListResponse,
ContactResponse,
ContactUpdateRequest,
AddressQualityCorrectionResponse,
AddressQualitySummaryResponse,
VCardImportIssue,
VCardImportRequest,
VCardImportResponse,
@@ -90,6 +106,7 @@ from govoplan_addresses.backend.service import (
AddressBookError,
available_address_credentials,
address_book_contact_counts,
address_quality_summary,
address_list_entry_counts,
create_address_book,
create_address_list,
@@ -97,6 +114,8 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source,
create_contact,
create_contact_channel_rule,
create_contact_quality_decision,
current_contact_quality,
create_sync_source,
count_contacts,
delete_address_book,
@@ -115,6 +134,9 @@ from govoplan_addresses.backend.service import (
list_address_books,
list_contacts,
list_contact_channel_rules,
list_contact_field_provenance,
list_contact_merges,
list_contact_quality_decisions,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
@@ -122,9 +144,12 @@ from govoplan_addresses.backend.service import (
record_sync_conflict,
record_sync_diagnostic,
record_sync_tombstone,
merge_contacts,
recover_contact_merge,
restore_address_book,
restore_address_list,
restore_contact,
resolve_contact_redirect,
resolve_sync_conflict,
preview_sync_source,
public_address_sync_metadata,
@@ -133,6 +158,7 @@ from govoplan_addresses.backend.service import (
update_address_book,
update_address_list,
update_contact,
suggest_duplicate_contacts,
update_sync_source,
)
@@ -172,14 +198,123 @@ def _book_response(book: AddressBook, *, contact_count: int = 0) -> AddressBookR
)
def _contact_response(contact: Contact) -> ContactResponse:
return ContactResponse.model_validate(contact)
def _contact_response(
contact: Contact,
*,
field_provenance: list | None = None,
) -> ContactResponse:
quality = current_contact_quality(contact)
def quality_payload(channel: str, point_id: str) -> dict:
decision = quality.get((channel, point_id)) or quality.get((channel, None))
return {
"quality_state": decision.state if decision is not None else "valid",
"quality_reason_code": (
decision.reason_code if decision is not None else None
),
}
return ContactResponse.model_validate(
{
"id": contact.id,
"tenant_id": contact.tenant_id,
"address_book_id": contact.address_book_id,
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
"source_payload_kind": contact.source_payload_kind,
"source_revision": contact.source_revision,
"provenance": dict(contact.provenance or {}),
"emails": [
{
"id": item.id,
"label": item.label,
"email": item.email,
"original_email": item.original_email or item.email,
"normalized_email": item.normalized_email or item.email.casefold(),
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("email", item.id),
}
for item in contact.emails
],
"phones": [
{
"id": item.id,
"label": item.label,
"phone": item.phone,
"original_phone": item.original_phone or item.phone,
"normalized_phone": item.normalized_phone or item.phone,
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("phone", item.id),
}
for item in contact.phones
],
"postal_addresses": [
{
"id": item.id,
"label": item.label,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"original_value": dict(item.original_value or {}),
"normalized_value": dict(item.normalized_value or {}),
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("postal", item.id),
}
for item in contact.postal_addresses
],
"field_provenance": field_provenance or [],
"deleted_at": contact.deleted_at,
"created_at": contact.created_at,
"updated_at": contact.updated_at,
}
)
def _contact_point_audit_details(
contact: Contact,
*,
prefix: str = "",
) -> dict[str, object]:
key_prefix = f"{prefix}_" if prefix else ""
point_ids = {
"email": [item.id for item in contact.emails],
"phone": [item.id for item in contact.phones],
"postal": [item.id for item in contact.postal_addresses],
}
return {
f"{key_prefix}contact_point_counts": {
channel: len(ids) for channel, ids in point_ids.items()
},
f"{key_prefix}contact_point_ids": point_ids,
}
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
return ContactChannelRuleResponse.model_validate(rule)
def _quality_decision_response(
decision: ContactPointQualityDecision,
) -> ContactPointQualityDecisionResponse:
return ContactPointQualityDecisionResponse.model_validate(decision)
def _merge_response(record: ContactMergeRecord) -> ContactMergeRecordResponse:
return ContactMergeRecordResponse.model_validate(record)
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
return AddressListResponse.model_validate(
{
@@ -543,6 +678,339 @@ def api_lookup_addresses(
return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts])
@router.get(
"/address-books/{book_id}/duplicate-suggestions",
response_model=ContactDuplicateSuggestionListResponse,
)
def api_suggest_duplicate_contacts(
book_id: str,
contact_id: str | None = Query(default=None),
minimum_score: int = Query(default=40, ge=1, le=100),
limit: int = Query(default=100, ge=1, le=100),
scan_limit: int = Query(default=500, ge=2, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
scan = suggest_duplicate_contacts(
session,
principal,
address_book_id=book_id,
contact_id=contact_id,
minimum_score=minimum_score,
limit=limit,
scan_limit=scan_limit,
)
return ContactDuplicateSuggestionListResponse(
suggestions=[
ContactDuplicateSuggestionResponse(
left=_contact_response(item.left),
right=_contact_response(item.right),
score=item.score,
confidence=item.confidence,
features=[
ContactDuplicateFeatureResponse(**asdict(feature))
for feature in item.features
],
)
for item in scan.suggestions
],
scanned_contacts=scan.scanned_contacts,
candidate_pairs=scan.candidate_pairs,
truncated=scan.truncated,
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/address-books/{book_id}/quality-summary",
response_model=AddressQualitySummaryResponse,
)
def api_address_quality_summary(
book_id: str,
correction_limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
summary = address_quality_summary(
session,
principal,
address_book_id=book_id,
correction_limit=correction_limit,
)
return AddressQualitySummaryResponse(
contact_count=summary.contact_count,
contact_point_count=summary.contact_point_count,
quality_counts=summary.quality_counts,
duplicate_suggestion_count=summary.duplicate_suggestion_count,
correction_count=summary.correction_count,
corrections=[
AddressQualityCorrectionResponse(**asdict(item))
for item in summary.corrections
],
truncated=summary.truncated,
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/quality-decisions",
response_model=ContactPointQualityDecisionListResponse,
)
def api_list_contact_quality_decisions(
contact_id: str,
include_ended: bool = Query(default=True),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:read")
try:
return ContactPointQualityDecisionListResponse(
decisions=[
_quality_decision_response(item)
for item in list_contact_quality_decisions(
session,
principal,
contact_id,
include_ended=include_ended,
)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contacts/{contact_id}/quality-decisions",
response_model=ContactPointQualityDecisionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_contact_quality_decision(
contact_id: str,
payload: ContactPointQualityDecisionCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
decision = create_contact_quality_decision(
session,
principal,
contact_id,
payload,
)
audit_from_principal(
session,
principal,
action="addresses.contact_quality_changed",
object_type="address_contact_quality_decision",
object_id=decision.id,
details={
"contact_id": contact_id,
"channel": decision.channel,
"contact_point_id": decision.contact_point_id,
"state": decision.state,
"reason_code": decision.reason_code,
"evidence_ref": decision.evidence_ref,
},
)
session.commit()
session.refresh(decision)
return _quality_decision_response(decision)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/provenance",
response_model=list[ContactFieldProvenanceResponse],
)
def api_list_contact_provenance(
contact_id: str,
current_only: bool = Query(default=False),
limit: int = Query(default=500, ge=1, le=2000),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return [
ContactFieldProvenanceResponse.model_validate(item)
for item in list_contact_field_provenance(
session,
principal,
contact_id,
current_only=current_only,
limit=limit,
)
]
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/redirect",
response_model=ContactRedirectResponse,
)
def api_resolve_contact_redirect(
contact_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return ContactRedirectResponse.model_validate(
asdict(resolve_contact_redirect(session, principal, contact_id))
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get("/contact-merges", response_model=ContactMergeRecordListResponse)
def api_list_contact_merges(
address_book_id: str | None = Query(default=None),
contact_id: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return ContactMergeRecordListResponse(
merges=[
_merge_response(item)
for item in list_contact_merges(
session,
principal,
address_book_id=address_book_id,
contact_id=contact_id,
limit=limit,
)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contact-merges",
response_model=ContactMergeRecordResponse,
status_code=status.HTTP_201_CREATED,
)
def api_merge_contacts(
payload: ContactMergeRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
_require_scope(principal, "addresses:contact:delete")
try:
record = merge_contacts(session, principal, payload)
audit_from_principal(
session,
principal,
action="addresses.contacts_merged",
object_type="address_contact_merge",
object_id=record.id,
details={
"winner_contact_id": record.winner_contact_id,
"loser_contact_ids": list(record.loser_contact_ids),
"before_hash": record.before_hash,
"after_hash": record.after_hash,
"reason": record.reason,
},
)
session.commit()
session.refresh(record)
return _merge_response(record)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
def _recover_contact_merge_api(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal,
session: Session,
*,
action: str,
) -> ContactMergeRecordResponse:
_require_scope(principal, "addresses:contact:write")
try:
record = recover_contact_merge(
session,
principal,
merge_id,
payload,
action=action,
)
audit_from_principal(
session,
principal,
action=f"addresses.contact_merge_{action}",
object_type="address_contact_merge",
object_id=record.id,
details={
"winner_contact_id": record.winner_contact_id,
"loser_contact_ids": list(record.loser_contact_ids),
"expected_after_hash": payload.expected_after_hash,
"reason": payload.reason,
},
)
session.commit()
session.refresh(record)
return _merge_response(record)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.post(
"/contact-merges/{merge_id}/undo",
response_model=ContactMergeRecordResponse,
)
def api_undo_contact_merge(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
return _recover_contact_merge_api(
merge_id,
payload,
principal,
session,
action="undo",
)
@router.post(
"/contact-merges/{merge_id}/split",
response_model=ContactMergeRecordResponse,
)
def api_split_contact_merge(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
return _recover_contact_merge_api(
merge_id,
payload,
principal,
session,
action="split",
)
@router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse)
def api_resolve_contact_points(
payload: ContactPointResolveRequest,
@@ -774,6 +1242,19 @@ def api_create_address_list_entry(
_require_scope(principal, "addresses:address_list:write")
try:
entry = create_address_list_entry(session, principal, address_list_id, payload)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.address_list_entry_created",
object_type="address_list_entry",
object_id=entry.id,
details={
"address_list_id": entry.address_list_id,
"contact_id": entry.contact_id,
"target_kind": entry.target_kind,
},
)
session.commit()
session.refresh(entry)
return _address_list_entry_response(entry)
@@ -790,7 +1271,20 @@ def api_delete_address_list_entry(
):
_require_scope(principal, "addresses:address_list:write")
try:
entry = session.get(AddressListEntry, entry_id)
delete_address_list_entry(session, principal, entry_id)
audit_from_principal(
session,
principal,
action="addresses.address_list_entry_deleted",
object_type="address_list_entry",
object_id=entry_id,
details={
"address_list_id": entry.address_list_id if entry is not None else None,
"contact_id": entry.contact_id if entry is not None else None,
"target_kind": entry.target_kind if entry is not None else None,
},
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except AddressBookError as exc:
@@ -1234,6 +1728,19 @@ def api_create_contact(
_require_scope(principal, "addresses:contact:write")
try:
contact = create_contact(session, principal, book_id, payload)
audit_from_principal(
session,
principal,
action="addresses.contact_created",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"field_names": sorted(payload.model_fields_set),
**_contact_point_audit_details(contact),
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1251,7 +1758,27 @@ def api_update_contact(
):
_require_scope(principal, "addresses:contact:write")
try:
previous_contact = session.get(Contact, contact_id)
previous_point_details = (
_contact_point_audit_details(previous_contact, prefix="previous")
if previous_contact is not None
else {}
)
contact = update_contact(session, principal, contact_id, payload)
audit_from_principal(
session,
principal,
action="addresses.contact_updated",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"field_names": sorted(payload.model_fields_set),
**previous_point_details,
**_contact_point_audit_details(contact),
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1360,6 +1887,18 @@ def api_delete_contact(
_require_scope(principal, "addresses:contact:delete")
try:
delete_contact(session, principal, contact_id)
contact = session.get(Contact, contact_id)
audit_from_principal(
session,
principal,
action="addresses.contact_deleted",
object_type="address_contact",
object_id=contact_id,
details={
"address_book_id": contact.address_book_id if contact is not None else None,
"source_kind": contact.source_kind if contact is not None else None,
},
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except AddressBookError as exc:
@@ -1376,6 +1915,17 @@ def api_restore_contact(
_require_scope(principal, "addresses:contact:write")
try:
contact = restore_contact(session, principal, contact_id)
audit_from_principal(
session,
principal,
action="addresses.contact_restored",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1394,6 +1944,20 @@ def api_import_address_book_vcards(
_require_scope(principal, "addresses:contact:write")
try:
result = import_vcards(session, principal, book_id, payload.content)
for contact in result.contacts:
audit_from_principal(
session,
principal,
action="addresses.contact_imported",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": book_id,
"source_kind": contact.source_kind,
"source_revision": contact.source_revision,
**_contact_point_audit_details(contact),
},
)
session.commit()
for contact in result.contacts:
session.refresh(contact)
+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")
File diff suppressed because it is too large Load Diff
+428 -1
View File
@@ -51,9 +51,13 @@ from govoplan_addresses.backend.db.models import (
Contact,
ContactChannelRule,
ContactEmail,
ContactFieldProvenance,
ContactMergeRecord,
ContactPhone,
ContactPointSnapshot,
ContactPointQualityDecision,
ContactPostalAddress,
ContactRedirect,
)
from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest,
@@ -71,13 +75,27 @@ from govoplan_addresses.backend.schemas import (
ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactEmailPayload,
ContactMergeRecoveryRequest,
ContactMergeRequest,
ContactPhonePayload,
ContactPointQualityDecisionCreateRequest,
ContactPostalAddressPayload,
ContactPointSnapshotResponse,
ContactUpdateRequest,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response
from govoplan_addresses.backend.router import (
_sync_source_response,
api_create_address_list_entry,
api_create_contact,
api_delete_address_list_entry,
api_delete_contact,
api_restore_contact,
api_update_contact,
)
from govoplan_addresses.backend.service import (
AddressBookError,
address_quality_summary,
address_book_contact_counts,
address_list_entry_counts,
create_address_book,
@@ -86,6 +104,7 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source,
create_contact,
create_contact_channel_rule,
create_contact_quality_decision,
create_sync_source,
count_contacts,
delete_address_list_entry,
@@ -100,6 +119,8 @@ from govoplan_addresses.backend.service import (
list_address_books,
list_contacts,
list_contact_channel_rules,
list_contact_field_provenance,
list_contact_merges,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
@@ -109,11 +130,16 @@ from govoplan_addresses.backend.service import (
record_sync_tombstone,
run_sync_source,
preview_sync_source,
merge_contacts,
recover_contact_merge,
restore_contact,
resolve_contact_redirect,
resolve_sync_conflict,
start_sync_attempt,
finish_sync_attempt,
update_sync_source,
update_contact,
suggest_duplicate_contacts,
resolve_trusted_deployment_carddav_credential_ref,
_carddav_client_for_source,
)
@@ -207,6 +233,10 @@ class AddressServiceTest(unittest.TestCase):
ContactPostalAddress.__table__,
ContactChannelRule.__table__,
ContactPointSnapshot.__table__,
ContactPointQualityDecision.__table__,
ContactMergeRecord.__table__,
ContactRedirect.__table__,
ContactFieldProvenance.__table__,
AddressListEntry.__table__,
AddressSyncSource.__table__,
AddressSyncTombstone.__table__,
@@ -279,6 +309,75 @@ class AddressServiceTest(unittest.TestCase):
self.session.commit()
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_contact_and_relationship_routes_emit_value_free_audit_evidence(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audited"),
)
self.session.commit()
with patch("govoplan_addresses.backend.router.audit_from_principal") as audit:
created = api_create_contact(
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email="ada@example.local")],
),
self.principal,
self.session,
)
create_details = audit.call_args.kwargs["details"]
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_created")
self.assertEqual(create_details["contact_point_counts"]["email"], 1)
self.assertNotIn("ada@example.local", repr(create_details))
original_email_id = create_details["contact_point_ids"]["email"][0]
updated = api_update_contact(
created.id,
ContactUpdateRequest(
emails=[ContactEmailPayload(email="ada.new@example.local")]
),
self.principal,
self.session,
)
update_details = audit.call_args.kwargs["details"]
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_updated")
self.assertEqual(update_details["previous_contact_point_ids"]["email"], [original_email_id])
self.assertNotEqual(update_details["contact_point_ids"]["email"], [original_email_id])
self.assertNotIn("ada.new@example.local", repr(update_details))
api_delete_contact(created.id, self.principal, self.session)
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_deleted")
api_restore_contact(created.id, self.principal, self.session)
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_restored")
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Audited list"),
)
self.session.commit()
entry = api_create_address_list_entry(
address_list.id,
AddressListEntryCreateRequest(
contact_id=updated.id,
contact_email_id=updated.emails[0].id,
),
self.principal,
self.session,
)
self.assertEqual(
audit.call_args.kwargs["action"],
"addresses.address_list_entry_created",
)
self.assertEqual(audit.call_args.kwargs["details"]["contact_id"], updated.id)
api_delete_address_list_entry(entry.id, self.principal, self.session)
self.assertEqual(
audit.call_args.kwargs["action"],
"addresses.address_list_entry_deleted",
)
def test_contact_windows_report_exact_totals(self) -> None:
book = create_address_book(
self.session,
@@ -605,6 +704,334 @@ END:VCARD
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id)
def test_contact_quality_preserves_originals_provenance_and_excludes_invalid_targets(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Quality review"),
)
self.session.flush()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email=" Ada@Example.LOCAL ")],
phones=[ContactPhonePayload(phone="+49 (30) 123 45")],
postal_addresses=[
ContactPostalAddressPayload(
street=" Main Street 1 ",
postal_code=" 10115 ",
locality=" Berlin ",
country=" Germany ",
)
],
provenance={
"field_visibility": {
"organization": "restricted",
}
},
),
)
self.session.commit()
self.session.refresh(contact)
self.assertEqual(contact.emails[0].email, "Ada@Example.LOCAL")
self.assertEqual(contact.emails[0].original_email, " Ada@Example.LOCAL ")
self.assertEqual(contact.emails[0].normalized_email, "ada@example.local")
self.assertEqual(contact.phones[0].original_phone, "+49 (30) 123 45")
self.assertEqual(contact.phones[0].normalized_phone, "+493012345")
self.assertEqual(contact.postal_addresses[0].original_value["street"], " Main Street 1 ")
self.assertEqual(contact.postal_addresses[0].normalized_value["street"], "main street 1")
initial_provenance = list_contact_field_provenance(
self.session,
self.principal,
contact.id,
current_only=True,
)
self.assertTrue(any(item.field_path == "display_name" for item in initial_provenance))
self.assertTrue(any(item.field_path.endswith(".email") for item in initial_provenance))
self.assertEqual(
next(item for item in initial_provenance if item.field_path == "organization").visibility,
"restricted",
)
update_contact(
self.session,
self.principal,
contact.id,
ContactUpdateRequest(organization="Analytical Engine Office"),
)
quality = create_contact_quality_decision(
self.session,
self.principal,
contact.id,
ContactPointQualityDecisionCreateRequest(
channel="email",
contact_point_id=contact.emails[0].id,
state="undeliverable",
reason_code="addresses.quality.smtp_hard_bounce",
reason="The remote server rejected this address permanently.",
evidence_ref="mail:delivery:42",
),
)
self.session.commit()
history = list_contact_field_provenance(
self.session,
self.principal,
contact.id,
)
self.assertTrue(any(not item.selected for item in history))
current_organization = next(
item
for item in history
if item.field_path == "organization" and item.selected
)
self.assertEqual(current_organization.value, "Analytical Engine Office")
self.assertEqual(current_organization.reason_code, "addresses.contact.quality_updated")
facts = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
),
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email",),
),
)
self.assertEqual(facts.candidates[0].status, "invalid")
self.assertEqual(facts.candidates[0].reason_code, "addresses.quality.smtp_hard_bounce")
self.assertEqual(facts.candidates[0].decision_provenance["quality_decision_id"], quality.id)
snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
self.session,
self.principal,
address_book_id=book.id,
purpose="campaign_delivery",
)
self.assertEqual(snapshot.recipients, ())
self.assertEqual(snapshot.excluded[0].reason_code, "addresses.quality.smtp_hard_bounce")
summary = address_quality_summary(
self.session,
self.principal,
address_book_id=book.id,
)
self.assertEqual(summary.contact_count, 1)
self.assertEqual(summary.contact_point_count, 3)
self.assertEqual(summary.quality_counts["undeliverable"], 1)
self.assertEqual(summary.correction_count, 1)
self.assertEqual(summary.corrections[0].contact_id, contact.id)
def test_duplicate_merge_recovery_preserves_references_and_rejects_tampering(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Duplicate review"),
)
self.session.flush()
winner = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
organization="Analytical Engine Office",
emails=[ContactEmailPayload(email="ada@example.local")],
),
)
loser = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
organization="Analytical Engine Office",
role_title="Mathematician",
emails=[
ContactEmailPayload(email="ADA@example.local"),
ContactEmailPayload(email="ada.private@example.local"),
],
),
)
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Recipients"),
)
self.session.flush()
original_loser_email_id = loser.emails[1].id
entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(
contact_id=loser.id,
contact_email_id=original_loser_email_id,
),
)
create_contact_quality_decision(
self.session,
self.principal,
loser.id,
ContactPointQualityDecisionCreateRequest(
channel="email",
contact_point_id=original_loser_email_id,
state="stale",
reason="This private address needs confirmation.",
),
)
self.session.commit()
scan = suggest_duplicate_contacts(
self.session,
self.principal,
address_book_id=book.id,
)
self.assertEqual(scan.scanned_contacts, 2)
self.assertEqual(scan.candidate_pairs, 1)
self.assertEqual(scan.suggestions[0].score, 100)
self.assertEqual(scan.suggestions[0].confidence, "strong")
self.assertEqual(
{feature.code for feature in scan.suggestions[0].features},
{"email_exact", "name_organization_exact"},
)
merge = merge_contacts(
self.session,
self.principal,
ContactMergeRequest(
winner_contact_id=winner.id,
duplicate_contact_ids=[loser.id],
reason="Confirmed duplicate record.",
field_sources={"role_title": loser.id},
contact_point_strategy="union",
),
)
merge_id = merge.id
after_hash = merge.after_hash
winner_id = winner.id
loser_id = loser.id
entry_id = entry.id
self.session.commit()
self.session.expire_all()
resolved = resolve_contact_redirect(self.session, self.principal, loser_id)
self.assertTrue(resolved.redirected)
self.assertEqual(resolved.resolved_contact_id, winner_id)
merged_winner = self.session.get(Contact, winner_id)
merged_loser = self.session.get(Contact, loser_id)
assert merged_winner is not None
assert merged_loser is not None
self.assertEqual(merged_winner.role_title, "Mathematician")
self.assertEqual(
{item.normalized_email for item in merged_winner.emails},
{"ada@example.local", "ada.private@example.local"},
)
self.assertIsNotNone(merged_loser.deleted_at)
merged_entry = self.session.get(AddressListEntry, entry_id)
assert merged_entry is not None
self.assertEqual(merged_entry.contact_id, winner_id)
self.assertNotEqual(merged_entry.contact_email_id, original_loser_email_id)
self.assertTrue(
any(
item.state == "stale"
and item.contact_point_id == merged_entry.contact_email_id
for item in merged_winner.quality_decisions
)
)
retained_role_title = next(
item
for item in list_contact_field_provenance(
self.session,
self.principal,
winner_id,
current_only=True,
)
if item.field_path == "role_title"
)
self.assertEqual(retained_role_title.source_ref, f"addresses:contact:{loser_id}")
self.assertEqual(retained_role_title.metadata_["source_contact_id"], loser_id)
self.assertEqual(list_contact_merges(self.session, self.principal)[0].id, merge_id)
merged_winner.note = "Changed after merge"
self.session.commit()
with self.assertRaisesRegex(AddressBookError, "changed after this merge"):
recover_contact_merge(
self.session,
self.principal,
merge_id,
ContactMergeRecoveryRequest(
reason="Correct the duplicate decision.",
expected_after_hash=after_hash,
),
action="undo",
)
self.session.rollback()
merged_winner = self.session.get(Contact, winner_id)
assert merged_winner is not None
merged_winner.note = None
self.session.commit()
recovered = recover_contact_merge(
self.session,
self.principal,
merge_id,
ContactMergeRecoveryRequest(
reason="Correct the duplicate decision.",
expected_after_hash=after_hash,
),
action="undo",
)
self.session.commit()
self.assertEqual(recovered.status, "undone")
self.session.expire_all()
restored_winner = self.session.get(Contact, winner_id)
restored_loser = self.session.get(Contact, loser_id)
restored_entry = self.session.get(AddressListEntry, entry_id)
assert restored_winner is not None
assert restored_loser is not None
assert restored_entry is not None
self.assertIsNone(restored_winner.role_title)
self.assertIsNone(restored_loser.deleted_at)
self.assertEqual(restored_entry.contact_id, loser_id)
self.assertEqual(restored_entry.contact_email_id, original_loser_email_id)
self.assertFalse(resolve_contact_redirect(self.session, self.principal, loser_id).redirected)
second_merge = merge_contacts(
self.session,
self.principal,
ContactMergeRequest(
winner_contact_id=winner_id,
duplicate_contact_ids=[loser_id],
reason="Re-run duplicate decision.",
),
)
self.session.commit()
split = recover_contact_merge(
self.session,
self.principal,
second_merge.id,
ContactMergeRecoveryRequest(
reason="Split records after review.",
expected_after_hash=second_merge.after_hash,
),
action="split",
)
self.session.commit()
self.assertEqual(split.status, "split")
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
+232
View File
@@ -39,6 +39,11 @@ export type ContactEmail = {
id?: string;
label?: string | null;
email: string;
original_email?: string;
normalized_email?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
@@ -46,6 +51,11 @@ export type ContactPhone = {
id?: string;
label?: string | null;
phone: string;
original_phone?: string;
normalized_phone?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
@@ -57,9 +67,35 @@ export type ContactPostalAddress = {
locality?: string | null;
region?: string | null;
country?: string | null;
original_value?: Record<string, unknown>;
normalized_value?: Record<string, unknown>;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
export type ContactPointQualityState = "valid" | "invalid" | "returned" | "stale" | "undeliverable";
export type ContactFieldProvenance = {
id: string;
contact_id: string;
field_path: string;
value?: unknown;
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
precedence: number;
selected: boolean;
reason_code: string;
explanation?: string | null;
visibility: "inherit" | "private" | "restricted" | "public";
merge_record_id?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
};
export type Contact = {
id: string;
tenant_id?: string | null;
@@ -79,11 +115,95 @@ export type Contact = {
emails: ContactEmail[];
phones: ContactPhone[];
postal_addresses: ContactPostalAddress[];
field_provenance?: ContactFieldProvenance[];
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type ContactPointQualityDecision = {
id: string;
tenant_id?: string | null;
contact_id: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
evidence_ref?: string | null;
effective_from: string;
effective_until?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type ContactDuplicateFeature = {
code: string;
label: string;
weight: number;
value: string;
};
export type ContactDuplicateSuggestion = {
left: Contact;
right: Contact;
score: number;
confidence: "possible" | "likely" | "strong";
features: ContactDuplicateFeature[];
};
export type ContactDuplicateSuggestionList = {
suggestions: ContactDuplicateSuggestion[];
scanned_contacts: number;
candidate_pairs: number;
truncated: boolean;
};
export type ContactMergeRecord = {
id: string;
tenant_id?: string | null;
address_book_id: string;
winner_contact_id: string;
loser_contact_ids: string[];
status: string;
reason: string;
survivorship: Record<string, unknown>;
decisions: Array<Record<string, unknown>>;
before_hash: string;
after_hash: string;
created_by_account_id?: string | null;
recovered_at?: string | null;
recovered_by_account_id?: string | null;
recovery_action?: string | null;
recovery_reason?: string | null;
provenance: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressQualityCorrection = {
contact_id: string;
display_name: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
effective_from: string;
};
export type AddressQualitySummary = {
contact_count: number;
contact_point_count: number;
quality_counts: Record<string, number>;
duplicate_suggestion_count: number;
correction_count: number;
corrections: AddressQualityCorrection[];
truncated: boolean;
};
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
export type AddressChannelDecision =
| "allowed"
@@ -392,6 +512,14 @@ type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
type ContactPointQualityDecisionListResponse = {
decisions: ContactPointQualityDecision[];
};
type ContactMergeRecordListResponse = {
merges: ContactMergeRecord[];
};
function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -677,6 +805,110 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
}
export function getAddressQualitySummary(settings: ApiSettings, addressBookId: string): Promise<AddressQualitySummary> {
return apiFetch<AddressQualitySummary>(settings, `/api/v1/addresses/address-books/${addressBookId}/quality-summary`);
}
export function listContactDuplicateSuggestions(
settings: ApiSettings,
addressBookId: string,
options: { contactId?: string | null; minimumScore?: number; limit?: number; scanLimit?: number } = {}
): Promise<ContactDuplicateSuggestionList> {
return apiFetch<ContactDuplicateSuggestionList>(
settings,
`/api/v1/addresses/address-books/${addressBookId}/duplicate-suggestions${queryString({
contact_id: options.contactId,
minimum_score: options.minimumScore,
limit: options.limit,
scan_limit: options.scanLimit
})}`
);
}
export async function listContactQualityDecisions(settings: ApiSettings, contactId: string): Promise<ContactPointQualityDecision[]> {
const response = await apiFetch<ContactPointQualityDecisionListResponse>(
settings,
`/api/v1/addresses/contacts/${contactId}/quality-decisions`
);
return response.decisions;
}
export function createContactQualityDecision(
settings: ApiSettings,
contactId: string,
payload: {
channel: ContactPointQualityDecision["channel"];
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code?: string | null;
reason?: string | null;
evidence_ref?: string | null;
}
): Promise<ContactPointQualityDecision> {
return apiFetch<ContactPointQualityDecision>(settings, `/api/v1/addresses/contacts/${contactId}/quality-decisions`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listContactProvenance(
settings: ApiSettings,
contactId: string,
options: { currentOnly?: boolean; limit?: number } = {}
): Promise<ContactFieldProvenance[]> {
return apiFetch<ContactFieldProvenance[]>(
settings,
`/api/v1/addresses/contacts/${contactId}/provenance${queryString({
current_only: options.currentOnly ? "true" : null,
limit: options.limit
})}`
);
}
export async function listContactMerges(
settings: ApiSettings,
options: { addressBookId?: string | null; contactId?: string | null; limit?: number } = {}
): Promise<ContactMergeRecord[]> {
const response = await apiFetch<ContactMergeRecordListResponse>(
settings,
`/api/v1/addresses/contact-merges${queryString({
address_book_id: options.addressBookId,
contact_id: options.contactId,
limit: options.limit
})}`
);
return response.merges;
}
export function mergeContacts(
settings: ApiSettings,
payload: {
winner_contact_id: string;
duplicate_contact_ids: string[];
reason: string;
field_sources?: Record<string, string>;
contact_point_strategy?: "union" | "winner_only";
source_precedence?: string[];
}
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, "/api/v1/addresses/contact-merges", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function recoverContactMerge(
settings: ApiSettings,
merge: ContactMergeRecord,
action: "undo" | "split",
reason: string
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, `/api/v1/addresses/contact-merges/${merge.id}/${action}`, {
method: "POST",
body: JSON.stringify({ reason, expected_after_hash: merge.after_hash })
});
}
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
const response = await apiFetch<ContactChannelRuleListResponse>(
settings,
@@ -1,4 +1,4 @@
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { Download, Edit3, GitMerge, History, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
ApiError,
@@ -12,6 +12,7 @@ import {
formatDateTime,
FormField,
LoadingFrame,
MetricCard,
PasswordField,
SegmentedControl,
SelectionList,
@@ -31,6 +32,7 @@ import {
createCardDavSyncSource,
createContact,
createContactChannelRule,
createContactQualityDecision,
deleteAddressBook,
deleteAddressList,
deleteAddressListEntry,
@@ -41,6 +43,7 @@ import {
exportAddressBookVcards,
exportContactVcard,
importAddressBookVcards,
getAddressQualitySummary,
listAddressBooks,
listAddressCredentials,
listAddressListEntries,
@@ -52,7 +55,12 @@ import {
listContacts,
listContactsPage,
listContactChannelRules,
listContactDuplicateSuggestions,
listContactMerges,
listContactProvenance,
previewAddressSyncSource,
mergeContacts,
recoverContactMerge,
restoreAddressBook,
restoreAddressList,
restoreContact,
@@ -75,9 +83,15 @@ import {
type AddressSyncPlan,
type AddressSyncSource,
type AddressSyncTombstone,
type AddressQualitySummary,
type Contact,
type ContactChannelRule,
type ContactChannelRulePayload
type ContactChannelRulePayload,
type ContactDuplicateSuggestion,
type ContactFieldProvenance,
type ContactMergeRecord,
type ContactPointQualityDecision,
type ContactPointQualityState
} from "../../api/addresses";
type Props = {
@@ -210,6 +224,65 @@ type AddressTreeNode = {
type ConflictMergeChoice = "local" | "remote";
type QualityPointTarget = {
contact: Contact;
channel: ContactPointQualityDecision["channel"];
contactPointId: string;
label: string;
currentState: ContactPointQualityState;
};
type QualityFormState = {
state: ContactPointQualityState;
reason_code: string;
reason: string;
evidence_ref: string;
};
type MergeDialogState = {
suggestion: ContactDuplicateSuggestion;
winnerId: string;
contactPointStrategy: "union" | "winner_only";
fieldSources: Record<string, string>;
reason: string;
};
type MergeRecoveryDialogState = {
merge: ContactMergeRecord;
action: "undo" | "split";
reason: string;
};
const MERGE_SCALAR_FIELDS = [
{ id: "display_name", label: "Display name" },
{ id: "given_name", label: "Given name" },
{ id: "family_name", label: "Family name" },
{ id: "organization", label: "Organization" },
{ id: "role_title", label: "Role title" },
{ id: "note", label: "Note" }
] as const;
type MergeScalarField = typeof MERGE_SCALAR_FIELDS[number]["id"];
function contactScalarValue(contact: Contact, field: MergeScalarField): string {
const value = contact[field];
return typeof value === "string" && value.trim() ? value : "Not set";
}
function defaultMergeFieldSources(
suggestion: ContactDuplicateSuggestion,
winnerId: string
): Record<string, string> {
const winner = suggestion.left.id === winnerId ? suggestion.left : suggestion.right;
const loser = suggestion.left.id === winnerId ? suggestion.right : suggestion.left;
return Object.fromEntries(
MERGE_SCALAR_FIELDS.map(({ id }) => [
id,
contactScalarValue(winner, id) !== "Not set" ? winner.id : loser.id
])
);
}
const EMPTY_BOOK_FORM: BookFormState = {
scope_type: "user",
group_id: "",
@@ -247,6 +320,13 @@ const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
effective_until: ""
};
const EMPTY_QUALITY_FORM: QualityFormState = {
state: "valid",
reason_code: "",
reason: "",
evidence_ref: ""
};
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
@@ -322,6 +402,34 @@ function primaryPhone(contact: Contact): string {
return contact.phones.find((phone) => phone.is_primary)?.phone ?? contact.phones[0]?.phone ?? "";
}
function contactPointSource(provenance?: Record<string, unknown>): string {
if (!provenance) return "";
const sourceKind = typeof provenance.source_kind === "string" ? provenance.source_kind : "";
const sourceRef = typeof provenance.source_ref === "string" ? provenance.source_ref : "";
return [sourceKind, sourceRef].filter(Boolean).join(" · ");
}
function originalPostalSummary(address: Contact["postal_addresses"][number]): string {
const value = address.original_value;
if (!value) return "";
return [
value.street,
[value.postal_code, value.locality].filter(Boolean).join(" "),
value.region,
value.country
].filter((item): item is string => typeof item === "string" && Boolean(item.trim())).join(", ");
}
function provenanceValue(value: unknown): string {
if (value === null || value === undefined || value === "") return "Not set";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function sourceGroupForBook(book: AddressBook): "local" | "linked" {
return book.source_kind === "local" ? "local" : "linked";
}
@@ -687,6 +795,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
const [qualityOpen, setQualityOpen] = useState(false);
const [qualityLoading, setQualityLoading] = useState(false);
const [qualitySummary, setQualitySummary] = useState<AddressQualitySummary | null>(null);
const [duplicateSuggestions, setDuplicateSuggestions] = useState<ContactDuplicateSuggestion[]>([]);
const [contactMerges, setContactMerges] = useState<ContactMergeRecord[]>([]);
const [provenanceContact, setProvenanceContact] = useState<Contact | null>(null);
const [contactProvenance, setContactProvenance] = useState<ContactFieldProvenance[]>([]);
const [provenanceLoading, setProvenanceLoading] = useState(false);
const [showProvenanceHistory, setShowProvenanceHistory] = useState(false);
const [qualityPointTarget, setQualityPointTarget] = useState<QualityPointTarget | null>(null);
const [qualityForm, setQualityForm] = useState<QualityFormState>(EMPTY_QUALITY_FORM);
const [mergeDialog, setMergeDialog] = useState<MergeDialogState | null>(null);
const [mergeRecoveryDialog, setMergeRecoveryDialog] = useState<MergeRecoveryDialogState | null>(null);
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
const [memberQuery, setMemberQuery] = useState("");
@@ -853,6 +974,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[!canWriteSync, "You need permission to run address sync."],
[saving, savingReason]
);
const qualityDashboardReason = disabledReason(
[!selectedBook, "Select an address book before reviewing address quality."],
[!canReadGovernance, "You need permission to view address quality."],
[saving, savingReason]
);
const createContactReason = disabledReason(
[!selectedBook, "Select an address book before adding a contact."],
[!canWriteContacts, "You need permission to manage contacts."],
@@ -1260,6 +1386,166 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
}
async function refreshQualityReview(bookId: string) {
setQualityLoading(true);
try {
const [summary, duplicates, merges] = await Promise.all([
getAddressQualitySummary(settings, bookId),
listContactDuplicateSuggestions(settings, bookId),
listContactMerges(settings, { addressBookId: bookId, limit: 100 })
]);
setQualitySummary(summary);
setDuplicateSuggestions(duplicates.suggestions);
setContactMerges(merges);
} finally {
setQualityLoading(false);
}
}
async function openQualityReview() {
if (!selectedBook) return;
setQualityOpen(true);
setError("");
try {
await refreshQualityReview(selectedBook.id);
} catch (err) {
setError(errorMessage(err));
setQualityOpen(false);
}
}
async function openProvenanceDialog(contact: Contact) {
setProvenanceContact(contact);
setContactProvenance([]);
setShowProvenanceHistory(false);
setProvenanceLoading(true);
setError("");
try {
setContactProvenance(await listContactProvenance(settings, contact.id, { limit: 2000 }));
} catch (err) {
setError(errorMessage(err));
setProvenanceContact(null);
} finally {
setProvenanceLoading(false);
}
}
function openQualityPointEditor(target: QualityPointTarget) {
setQualityPointTarget(target);
setQualityForm({
...EMPTY_QUALITY_FORM,
state: target.currentState
});
}
async function submitQualityDecision(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!qualityPointTarget || !canWriteGovernance) return;
setSaving(true);
setError("");
setNotice("");
try {
await createContactQualityDecision(settings, qualityPointTarget.contact.id, {
channel: qualityPointTarget.channel,
contact_point_id: qualityPointTarget.contactPointId,
state: qualityForm.state,
reason_code: qualityForm.reason_code.trim() || null,
reason: qualityForm.reason.trim() || null,
evidence_ref: qualityForm.evidence_ref.trim() || null
});
setQualityPointTarget(null);
setNotice(`Quality state recorded for ${qualityPointTarget.label}.`);
await refreshContacts(selectedBookId, query);
if (qualityOpen && selectedBookId) await refreshQualityReview(selectedBookId);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openMergeDialog(suggestion: ContactDuplicateSuggestion, winnerId: string) {
setMergeDialog({
suggestion,
winnerId,
contactPointStrategy: "union",
fieldSources: defaultMergeFieldSources(suggestion, winnerId),
reason: "Confirmed duplicate during address quality review."
});
}
function changeMergeWinner(winnerId: string) {
setMergeDialog((current) => current ? {
...current,
winnerId,
fieldSources: defaultMergeFieldSources(current.suggestion, winnerId)
} : null);
}
async function submitContactMerge(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!mergeDialog || mergeDialog.reason.trim().length < 3) return;
const loser = mergeDialog.suggestion.left.id === mergeDialog.winnerId
? mergeDialog.suggestion.right
: mergeDialog.suggestion.left;
setSaving(true);
setError("");
setNotice("");
try {
await mergeContacts(settings, {
winner_contact_id: mergeDialog.winnerId,
duplicate_contact_ids: [loser.id],
reason: mergeDialog.reason.trim(),
field_sources: mergeDialog.fieldSources,
contact_point_strategy: mergeDialog.contactPointStrategy
});
const winner = mergeDialog.suggestion.left.id === mergeDialog.winnerId
? mergeDialog.suggestion.left
: mergeDialog.suggestion.right;
setMergeDialog(null);
setSelectedContactId(winner.id);
setNotice(`Merged duplicate contact into "${winner.display_name}".`);
await refreshBooks();
await refreshContacts(selectedBookId, query);
if (selectedListId) await refreshListEntries(selectedListId);
if (selectedBookId) await refreshQualityReview(selectedBookId);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function submitMergeRecovery(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3) return;
setSaving(true);
setError("");
setNotice("");
try {
await recoverContactMerge(
settings,
mergeRecoveryDialog.merge,
mergeRecoveryDialog.action,
mergeRecoveryDialog.reason.trim()
);
setNotice(
mergeRecoveryDialog.action === "undo"
? "Contact merge undone."
: "Merged contacts split back into their recorded pre-merge state."
);
setMergeRecoveryDialog(null);
await refreshBooks();
await refreshContacts(selectedBookId, query);
if (selectedListId) await refreshListEntries(selectedListId);
if (selectedBookId) await refreshQualityReview(selectedBookId);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
setContactForm((current) => ({
...current,
@@ -1439,8 +1725,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
tags: tagsFromForm(contactForm.tags),
emails,
phones,
postal_addresses,
provenance: {}
postal_addresses
};
try {
let savedContact: Contact;
@@ -1973,6 +2258,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return (
<>
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
<Button type="button" title="Review address quality" aria-label="Review address quality" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={15} /></Button>
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
@@ -2051,6 +2337,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
onClick={() => void openGovernanceDialog(selectedContact)}>
<ShieldCheck size={15} /> Governance
</Button>
<Button
type="button"
title="Field provenance"
aria-label="Field provenance"
disabledReason={disabledReason([saving, savingReason])}
onClick={() => void openProvenanceDialog(selectedContact)}>
<History size={15} /> Provenance
</Button>
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
@@ -2089,7 +2383,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContact.emails.map((email, index) => (
<div key={email.id ?? `${email.email}-${index}`}>
<dt>{email.label || "email"}{email.is_primary ? " · primary" : ""}</dt>
<dd>{email.email}</dd>
<dd className="address-contact-point-value">
<span>{email.email}</span>
<StatusBadge status={email.quality_state ?? "valid"} />
{email.id && <Button
type="button"
title="Record email quality"
aria-label={`Record quality for ${email.email}`}
disabledReason={disabledReason(
[!canWriteGovernance, "You need permission to manage address quality."],
[saving, savingReason]
)}
onClick={() => openQualityPointEditor({
contact: selectedContact,
channel: "email",
contactPointId: email.id as string,
label: email.email,
currentState: email.quality_state ?? "valid"
})}>
Quality
</Button>}
{email.original_email && email.original_email !== email.email && <small>Original: {email.original_email}</small>}
{contactPointSource(email.provenance) && <small>Source: {contactPointSource(email.provenance)}</small>}
</dd>
</div>
))}
</dl>
@@ -2102,7 +2418,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContact.phones.map((phone, index) => (
<div key={phone.id ?? `${phone.phone}-${index}`}>
<dt>{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}</dt>
<dd>{phone.phone}</dd>
<dd className="address-contact-point-value">
<span>{phone.phone}</span>
<StatusBadge status={phone.quality_state ?? "valid"} />
{phone.id && <Button
type="button"
title="Record phone quality"
aria-label={`Record quality for ${phone.phone}`}
disabledReason={disabledReason(
[!canWriteGovernance, "You need permission to manage address quality."],
[saving, savingReason]
)}
onClick={() => openQualityPointEditor({
contact: selectedContact,
channel: "phone",
contactPointId: phone.id as string,
label: phone.phone,
currentState: phone.quality_state ?? "valid"
})}>
Quality
</Button>}
{phone.original_phone && phone.original_phone !== phone.phone && <small>Original: {phone.original_phone}</small>}
{contactPointSource(phone.provenance) && <small>Source: {contactPointSource(phone.provenance)}</small>}
</dd>
</div>
))}
</dl>
@@ -2115,7 +2453,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContact.postal_addresses.map((address, index) => (
<div key={address.id ?? `${address.label}-${index}`}>
<dt>{address.label || "address"}{address.is_primary ? " · primary" : ""}</dt>
<dd>{formatPostalAddress(address) || "No formatted address."}</dd>
<dd className="address-contact-point-value">
<span>{formatPostalAddress(address) || "No formatted address."}</span>
<StatusBadge status={address.quality_state ?? "valid"} />
{address.id && <Button
type="button"
title="Record postal-address quality"
aria-label={`Record quality for ${formatPostalAddress(address) || "postal address"}`}
disabledReason={disabledReason(
[!canWriteGovernance, "You need permission to manage address quality."],
[saving, savingReason]
)}
onClick={() => openQualityPointEditor({
contact: selectedContact,
channel: "postal",
contactPointId: address.id as string,
label: formatPostalAddress(address) || "postal address",
currentState: address.quality_state ?? "valid"
})}>
Quality
</Button>}
{originalPostalSummary(address) && originalPostalSummary(address) !== formatPostalAddress(address) && <small>Original: {originalPostalSummary(address)}</small>}
{contactPointSource(address.provenance) && <small>Source: {contactPointSource(address.provenance)}</small>}
</dd>
</div>
))}
</dl>
@@ -2507,6 +2867,258 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
</Dialog>
<Dialog
open={Boolean(provenanceContact)}
title={provenanceContact ? `Field provenance · ${provenanceContact.display_name}` : "Field provenance"}
onClose={() => setProvenanceContact(null)}
closeDisabled={provenanceLoading}
className="address-quality-dialog"
footerClassName="button-row compact-actions"
footer={<Button type="button" onClick={() => setProvenanceContact(null)} disabledReason={provenanceLoading ? "Field provenance is loading." : ""}>Close</Button>}>
<LoadingFrame loading={provenanceLoading} label="Loading field provenance...">
<div className="address-provenance-layout">
<ToggleSwitch
label="Show history"
checked={showProvenanceHistory}
onChange={() => setShowProvenanceHistory((current) => !current)}
help="Include superseded source decisions as well as the currently retained values."
/>
<div className="address-quality-list address-provenance-list">
{contactProvenance.filter((item) => showProvenanceHistory || item.selected).length === 0 ?
<p className="muted address-quality-empty">No field provenance was recorded.</p> :
contactProvenance
.filter((item) => showProvenanceHistory || item.selected)
.map((item) => (
<article className="address-quality-row" key={item.id}>
<div className="address-quality-row-main">
<span className="address-quality-row-heading">
<strong>{item.field_path}</strong>
<StatusBadge status={item.selected ? "retained" : "superseded"} />
{item.visibility !== "inherit" && <StatusBadge status={item.visibility} />}
</span>
<span className="address-provenance-value">{provenanceValue(item.value)}</span>
<small>
Source: {item.source_kind}{item.source_ref ? ` · ${item.source_ref}` : ""}
{` · ${item.reason_code}`}
{` · ${formatDateTime(item.created_at, ADDRESS_DATE_TIME_OPTIONS)}`}
</small>
{item.explanation && <small>{item.explanation}</small>}
</div>
</article>
))
}
</div>
</div>
</LoadingFrame>
</Dialog>
<Dialog
open={qualityOpen}
title={selectedBook ? `Address quality · ${selectedBook.name}` : "Address quality"}
onClose={() => setQualityOpen(false)}
closeDisabled={saving}
className="address-quality-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => selectedBook && void refreshQualityReview(selectedBook.id)} disabledReason={disabledReason([qualityLoading, "Address quality is loading."], [saving, savingReason])}><RefreshCw size={15} /> Refresh</Button>
<Button type="button" onClick={() => setQualityOpen(false)} disabledReason={dialogCancelReason}>Close</Button>
</>
}>
<LoadingFrame loading={qualityLoading} label="Loading address quality...">
<div className="address-quality-layout">
{qualitySummary && <div className="metric-grid inside address-quality-metrics">
<MetricCard label="Contacts" value={qualitySummary.contact_count} tone="info" detail={`${qualitySummary.contact_point_count} contact points`} />
<MetricCard label="Corrections" value={qualitySummary.correction_count} tone={qualitySummary.correction_count > 0 ? "warning" : "good"} detail="Current non-valid states" />
<MetricCard label="Duplicates" value={qualitySummary.duplicate_suggestion_count} tone={qualitySummary.duplicate_suggestion_count > 0 ? "warning" : "good"} detail="Explainable suggestions" />
<MetricCard label="Undeliverable" value={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0)} tone={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0) > 0 ? "danger" : "good"} detail="Returned or undeliverable" />
</div>}
<section className="address-quality-section">
<div className="address-form-section-heading">
<div>
<strong>Duplicate suggestions</strong>
<p className="muted small-text">Scores are bounded and show the exact matching features. Choose which contact survives.</p>
</div>
</div>
{duplicateSuggestions.length === 0 ? <p className="muted">No duplicate suggestions above the current threshold.</p> :
<div className="address-quality-list">
{duplicateSuggestions.map((suggestion) => (
<article className="address-quality-row" key={`${suggestion.left.id}:${suggestion.right.id}`}>
<div className="address-quality-row-main">
<span className="address-quality-row-heading">
<strong>{suggestion.left.display_name}</strong>
<span>and</span>
<strong>{suggestion.right.display_name}</strong>
<StatusBadge status={suggestion.confidence} label={`${suggestion.score}% ${suggestion.confidence}`} />
</span>
<small>{suggestion.features.map((feature) => `${feature.label}: ${feature.value}`).join(" · ")}</small>
</div>
<div className="button-row compact-actions">
<Button type="button" onClick={() => openMergeDialog(suggestion, suggestion.left.id)} disabledReason={disabledReason([!canWriteContacts || !canDeleteContacts, "You need permission to edit and delete contacts."], [saving, savingReason])}><GitMerge size={15} /> Keep left</Button>
<Button type="button" onClick={() => openMergeDialog(suggestion, suggestion.right.id)} disabledReason={disabledReason([!canWriteContacts || !canDeleteContacts, "You need permission to edit and delete contacts."], [saving, savingReason])}><GitMerge size={15} /> Keep right</Button>
</div>
</article>
))}
</div>
}
</section>
<section className="address-quality-section">
<div className="address-form-section-heading">
<div>
<strong>Correction queue</strong>
<p className="muted small-text">Current quality states are also applied to campaign and distribution-list recipient resolution.</p>
</div>
</div>
{!qualitySummary || qualitySummary.corrections.length === 0 ? <p className="muted">No contact points need correction.</p> :
<div className="address-quality-list">
{qualitySummary.corrections.map((correction) => (
<button
type="button"
className="address-quality-row address-quality-row-button"
key={`${correction.contact_id}:${correction.channel}:${correction.contact_point_id ?? "all"}`}
onClick={() => {
setSelectedContactId(correction.contact_id);
setQualityOpen(false);
}}>
<span className="address-quality-row-main">
<span className="address-quality-row-heading"><strong>{correction.display_name}</strong><StatusBadge status={correction.state} /></span>
<small>{correction.channel.replace("_", " ")} · {correction.reason || correction.reason_code}</small>
</span>
</button>
))}
</div>
}
</section>
<section className="address-quality-section">
<div className="address-form-section-heading">
<div>
<strong>Merge history</strong>
<p className="muted small-text">Recovery is available only while the recorded post-merge state still matches.</p>
</div>
</div>
{contactMerges.length === 0 ? <p className="muted">No contact merges recorded.</p> :
<div className="address-quality-list">
{contactMerges.map((merge) => (
<article className="address-quality-row" key={merge.id}>
<div className="address-quality-row-main">
<span className="address-quality-row-heading"><History size={15} /><strong>{merge.reason}</strong><StatusBadge status={merge.status} /></span>
<small>{merge.loser_contact_ids.length} merged contact{merge.loser_contact_ids.length === 1 ? "" : "s"} · {formatDateTime(merge.created_at, ADDRESS_DATE_TIME_OPTIONS)}</small>
</div>
{merge.status === "active" && <div className="button-row compact-actions">
<Button type="button" onClick={() => setMergeRecoveryDialog({ merge, action: "undo", reason: "Undo merge after quality review." })} disabledReason={disabledReason([!canWriteContacts, "You need permission to edit contacts."], [saving, savingReason])}><RotateCcw size={15} /> Undo</Button>
<Button type="button" onClick={() => setMergeRecoveryDialog({ merge, action: "split", reason: "Split merged contacts after quality review." })} disabledReason={disabledReason([!canWriteContacts, "You need permission to edit contacts."], [saving, savingReason])}>Split</Button>
</div>}
</article>
))}
</div>
}
</section>
{qualitySummary?.truncated && <DismissibleAlert tone="warning" dismissible={false}>This bounded review is truncated. Narrow the address book or use the API for a complete staged review.</DismissibleAlert>}
</div>
</LoadingFrame>
</Dialog>
<Dialog
open={Boolean(qualityPointTarget)}
title={qualityPointTarget ? `Contact-point quality · ${qualityPointTarget.contact.display_name}` : "Contact-point quality"}
onClose={() => setQualityPointTarget(null)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setQualityPointTarget(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button>
</>
}>
<form id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
<FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField>
<div className="form-grid two">
<FormField label="Quality state">
<select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}>
<option value="valid">Valid</option>
<option value="invalid">Invalid</option>
<option value="returned">Returned</option>
<option value="stale">Stale</option>
<option value="undeliverable">Undeliverable</option>
</select>
</FormField>
<FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField>
</div>
<FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
</form>
</Dialog>
<Dialog
open={Boolean(mergeDialog)}
title="Merge duplicate contacts"
onClose={() => setMergeDialog(null)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setMergeDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button>
</>
}>
{mergeDialog && <form id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
<DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert>
<FormField label="Surviving contact">
<select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}>
<option value={mergeDialog.suggestion.left.id}>{mergeDialog.suggestion.left.display_name} · {primaryEmail(mergeDialog.suggestion.left) || "no email"}</option>
<option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option>
</select>
</FormField>
<div className="form-grid two address-merge-field-sources">
{MERGE_SCALAR_FIELDS.map((field) => (
<FormField label={`${field.label} source`} key={field.id}>
<select
value={mergeDialog.fieldSources[field.id] ?? mergeDialog.winnerId}
onChange={(event) => setMergeDialog((current) => current ? {
...current,
fieldSources: { ...current.fieldSources, [field.id]: event.target.value }
} : null)}>
<option value={mergeDialog.suggestion.left.id}>Left · {contactScalarValue(mergeDialog.suggestion.left, field.id)}</option>
<option value={mergeDialog.suggestion.right.id}>Right · {contactScalarValue(mergeDialog.suggestion.right, field.id)}</option>
</select>
</FormField>
))}
</div>
<FormField label="Contact points">
<SegmentedControl<"union" | "winner_only">
role="group"
size="equal"
ariaLabel="Contact-point merge strategy"
options={[{ id: "union", label: "Combine unique" }, { id: "winner_only", label: "Keep survivor only" }]}
value={mergeDialog.contactPointStrategy}
onChange={(contactPointStrategy) => setMergeDialog((current) => current ? { ...current, contactPointStrategy } : null)}
/>
</FormField>
<FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField>
</form>}
</Dialog>
<Dialog
open={Boolean(mergeRecoveryDialog)}
title={mergeRecoveryDialog?.action === "split" ? "Split merged contacts" : "Undo contact merge"}
onClose={() => setMergeRecoveryDialog(null)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setMergeRecoveryDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button>
</>
}>
{mergeRecoveryDialog && <form id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
<p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p>
<FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField>
</form>}
</Dialog>
<Dialog
open={memberDialogOpen}
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
+124
View File
@@ -420,6 +420,23 @@
overflow-wrap: anywhere;
}
.address-contact-point-value {
align-items: center;
display: grid;
gap: 4px 8px;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.address-contact-point-value > small {
color: var(--muted);
grid-column: 1 / -1;
}
.address-contact-point-value .btn {
min-height: 28px;
padding: 4px 8px;
}
.address-membership-row {
align-items: center;
display: flex;
@@ -543,6 +560,104 @@
width: min(980px, calc(100vw - 32px));
}
.dialog-panel.address-quality-dialog,
.address-quality-dialog .dialog-panel {
width: min(1120px, calc(100vw - 32px));
}
.address-quality-layout {
display: grid;
gap: 18px;
max-height: min(720px, calc(100vh - 210px));
overflow: auto;
padding-right: 2px;
}
.address-provenance-layout {
display: grid;
gap: 12px;
}
.address-provenance-list {
max-height: min(620px, calc(100vh - 300px));
}
.address-provenance-value {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.address-quality-empty {
padding: 10px 12px;
}
.address-merge-field-sources select {
min-width: 0;
text-overflow: ellipsis;
}
.address-quality-metrics {
margin-top: 0;
}
.address-quality-section,
.address-quality-list,
.address-quality-row-main {
display: grid;
gap: 8px;
}
.address-quality-section {
border-top: var(--border-line);
padding-top: 14px;
}
.address-quality-list {
border: var(--border-line);
border-radius: 6px;
max-height: 300px;
overflow: auto;
}
.address-quality-row {
align-items: center;
background: transparent;
border: 0;
border-bottom: var(--border-line);
color: inherit;
display: grid;
font: inherit;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 10px 12px;
text-align: left;
width: 100%;
}
.address-quality-row:last-child {
border-bottom: 0;
}
.address-quality-row-button {
cursor: pointer;
grid-template-columns: minmax(0, 1fr);
}
.address-quality-row-button:hover {
background: var(--panel-soft);
}
.address-quality-row-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.address-quality-row-main small {
color: var(--muted);
}
.address-governance-layout,
.address-governance-list {
display: grid;
@@ -637,4 +752,13 @@
.address-form-row-postal {
grid-template-columns: 1fr;
}
.address-quality-row,
.address-contact-point-value {
grid-template-columns: 1fr;
}
.address-contact-point-value > small {
grid-column: auto;
}
}