Implement address quality and reversible contact merges
This commit is contained in:
@@ -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
|
remote field payloads, can apply a stored remote vCard payload, and supports
|
||||||
manual per-field local/remote merge choices.
|
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
|
API-managed CardDAV credentials are encrypted inside the source record. Source
|
||||||
deletion physically removes that credential material and records a non-secret
|
deletion physically removes that credential material and records a non-secret
|
||||||
audit event in the same database transaction; destructive module retirement
|
audit event in the same database transaction; destructive module retirement
|
||||||
@@ -72,9 +82,9 @@ It must not own:
|
|||||||
effective function assignments
|
effective function assignments
|
||||||
- operational distribution lists/`Verteiler` with mixed recipient types
|
- 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.lookup`: read-only contact/recipient lookup for autocomplete.
|
||||||
- `addresses.recipient_source`: immutable recipient snapshots for campaign,
|
- `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
|
- `addresses.contact_point_resolution`: purpose-aware, channel-neutral
|
||||||
resolution and immutable snapshots for email, postal, internal-mail, and
|
resolution and immutable snapshots for email, postal, internal-mail, and
|
||||||
portal targets.
|
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:
|
`addresses.recipient_source` returns:
|
||||||
|
|
||||||
@@ -131,8 +145,20 @@ The corresponding HTTP API is available below `/api/v1/addresses`:
|
|||||||
- `POST /contact-point-snapshots`
|
- `POST /contact-point-snapshots`
|
||||||
- `GET /contact-point-snapshots/{snapshot_id}`
|
- `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
|
## Design Documents
|
||||||
|
|
||||||
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
|
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
|
||||||
- [Implementation plan](docs/IMPLEMENTATION_PLAN.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)
|
- [AdreMa capability assessment and Distribution Lists roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/src/branch/main/docs/ADREMA_CAPABILITY_ASSESSMENT.md)
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ representation for import/export and conflict handling.
|
|||||||
|
|
||||||
The local baseline implements scoped address books, contacts, normalized
|
The local baseline implements scoped address books, contacts, normalized
|
||||||
email/phone/postal-address tables, tags, source kind/reference fields,
|
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.
|
vCards preserve raw source payload and revision metadata for audit/debugging.
|
||||||
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
|
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
|
||||||
first-class backend tables and API resources. Connector-specific diffing,
|
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
|
removal. An unowned legacy reference is detached rather than passed to an
|
||||||
external secret provider.
|
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
|
## Connector Direction
|
||||||
|
|
||||||
Implement connectors in this order:
|
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:
|
The following are valuable but not required for the first functional milestone:
|
||||||
|
|
||||||
- automatic deduplication and merge suggestions
|
|
||||||
- two-way sync conflict UI
|
- two-way sync conflict UI
|
||||||
- Microsoft/Google connectors
|
- Microsoft/Google connectors
|
||||||
- richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
|
- richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
|
||||||
|
|||||||
+16
-10
@@ -219,17 +219,19 @@ Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`,
|
|||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- LDAP/Active Directory read-only directory connector
|
- [ ] LDAP/Active Directory read-only directory connector
|
||||||
- Exchange/Microsoft 365 contacts connector
|
- [ ] Exchange/Microsoft 365 contacts connector
|
||||||
- Google Contacts connector
|
- [ ] Google Contacts connector
|
||||||
- CSV/XLSX/LDIF import mapping profiles
|
- [ ] CSV/XLSX/LDIF import mapping profiles
|
||||||
- classical address-list UI; reusable static/dynamic operational segments move
|
- [x] classical address-list UI; reusable static/dynamic operational segments move
|
||||||
to `govoplan-dist-lists`
|
to `govoplan-dist-lists`
|
||||||
- operational distribution lists move to `govoplan-dist-lists`
|
- [x] operational distribution lists move to `govoplan-dist-lists`
|
||||||
- consent, legal-basis, suppression, and communication preferences
|
- [x] consent, legal-basis, suppression, and communication preferences
|
||||||
- deduplication and merge workflow
|
- [x] bounded, explainable deduplication and reversible merge/split workflow
|
||||||
- address quality checks and normalization
|
- [x] contact-point quality states, normalization, original-value preservation,
|
||||||
- richer vCard `KIND`/`RELATED` round-trip and stable links to IDM/Organizations
|
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:
|
Exit criteria:
|
||||||
|
|
||||||
@@ -237,6 +239,10 @@ Exit criteria:
|
|||||||
- users can understand where data came from and whether they may edit it
|
- users can understand where data came from and whether they may edit it
|
||||||
- downstream modules can safely use contacts without owning them
|
- 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
|
## First Implementation Recommendation
|
||||||
|
|
||||||
Start with Milestone 1 and enough of Milestone 2 to define the data model
|
Start with Milestone 1 and enough of Milestone 2 to define the data model
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -41,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
ContactChannelRule,
|
ContactChannelRule,
|
||||||
ContactEmail,
|
ContactEmail,
|
||||||
ContactPhone,
|
ContactPhone,
|
||||||
|
ContactPointQualityDecision,
|
||||||
ContactPointSnapshot,
|
ContactPointSnapshot,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,7 @@ from govoplan_addresses.backend.schemas import ContactCreateRequest
|
|||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
AddressBookError,
|
AddressBookError,
|
||||||
create_contact,
|
create_contact,
|
||||||
|
current_contact_quality,
|
||||||
get_visible_address_book,
|
get_visible_address_book,
|
||||||
get_visible_address_list,
|
get_visible_address_list,
|
||||||
get_visible_contact,
|
get_visible_contact,
|
||||||
@@ -55,6 +57,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
list_address_list_entries,
|
list_address_list_entries,
|
||||||
list_address_lists,
|
list_address_lists,
|
||||||
list_contacts,
|
list_contacts,
|
||||||
|
resolve_contact_redirect,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -488,6 +491,7 @@ class AddressesChannelFactsCapability:
|
|||||||
and _aware_datetime(rule.effective_until) <= effective_at
|
and _aware_datetime(rule.effective_until) <= effective_at
|
||||||
]
|
]
|
||||||
revision, fingerprint = _contact_channel_revision(contact)
|
revision, fingerprint = _contact_channel_revision(contact)
|
||||||
|
quality = current_contact_quality(contact, effective_at=effective_at)
|
||||||
source = DistributionSourceReference(
|
source = DistributionSourceReference(
|
||||||
provider="addresses",
|
provider="addresses",
|
||||||
resource_type="contact",
|
resource_type="contact",
|
||||||
@@ -529,6 +533,20 @@ class AddressesChannelFactsCapability:
|
|||||||
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
|
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(
|
candidates.append(
|
||||||
DistributionChannelCandidate(
|
DistributionChannelCandidate(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -551,6 +569,17 @@ class AddressesChannelFactsCapability:
|
|||||||
"legal_basis": selected.legal_basis if selected is not None else None,
|
"legal_basis": selected.legal_basis if selected is not None else None,
|
||||||
"evidence_ref": selected.evidence_ref 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,
|
"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)
|
.group_by(Contact.address_book_id)
|
||||||
.all()
|
.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:
|
if updated_at is None:
|
||||||
continue
|
continue
|
||||||
key = str(address_book_id)
|
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)
|
.group_by(AddressListEntry.address_list_id)
|
||||||
.all()
|
.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 = (
|
postal_rows = (
|
||||||
session.query(
|
session.query(
|
||||||
AddressListEntry.address_list_id,
|
AddressListEntry.address_list_id,
|
||||||
@@ -1241,6 +1301,7 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
|
|||||||
*email_rows,
|
*email_rows,
|
||||||
*postal_rows,
|
*postal_rows,
|
||||||
*rule_rows,
|
*rule_rows,
|
||||||
|
*quality_rows,
|
||||||
]:
|
]:
|
||||||
if updated_at is None:
|
if updated_at is None:
|
||||||
continue
|
continue
|
||||||
@@ -1359,7 +1420,15 @@ def _channel_rule_explanation(rule: ContactChannelRule) -> str:
|
|||||||
|
|
||||||
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
|
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
|
||||||
stamps = [contact.updated_at]
|
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()
|
revision = max(_aware_datetime(item) for item in stamps).isoformat()
|
||||||
payload = {
|
payload = {
|
||||||
"contact_id": contact.id,
|
"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)
|
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(
|
fingerprint = hashlib.sha256(
|
||||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
@@ -1426,7 +1509,21 @@ def _contacts_for_subject(
|
|||||||
try:
|
try:
|
||||||
return [get_visible_contact(session, principal, str(direct_id))]
|
return [get_visible_contact(session, principal, str(direct_id))]
|
||||||
except AddressBookError:
|
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")
|
explicit_ref = subject.metadata.get("source_ref")
|
||||||
source_refs = {
|
source_refs = {
|
||||||
@@ -1445,16 +1542,24 @@ def _contacts_for_subject(
|
|||||||
.filter(
|
.filter(
|
||||||
Contact.source_ref.in_(source_refs),
|
Contact.source_ref.in_(source_refs),
|
||||||
or_(Contact.tenant_id == principal.tenant_id, Contact.tenant_id.is_(None)),
|
or_(Contact.tenant_id == principal.tenant_id, Contact.tenant_id.is_(None)),
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
)
|
||||||
.order_by(Contact.id.asc())
|
.order_by(Contact.id.asc())
|
||||||
.limit(3)
|
.limit(3)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
visible: list[Contact] = []
|
visible: list[Contact] = []
|
||||||
|
visible_ids: set[str] = set()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
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:
|
except AddressBookError:
|
||||||
continue
|
continue
|
||||||
return visible
|
return visible
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ class Contact(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="ContactChannelRule.created_at",
|
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):
|
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)
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
label: Mapped[str | None] = mapped_column(String(80))
|
label: Mapped[str | None] = mapped_column(String(80))
|
||||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
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)
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, 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)
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
label: Mapped[str | None] = mapped_column(String(80))
|
label: Mapped[str | None] = mapped_column(String(80))
|
||||||
phone: Mapped[str] = mapped_column(String(100), nullable=False)
|
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)
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, 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))
|
locality: Mapped[str | None] = mapped_column(String(255))
|
||||||
region: Mapped[str | None] = mapped_column(String(255))
|
region: Mapped[str | None] = mapped_column(String(255))
|
||||||
country: 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)
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, 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)
|
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):
|
class AddressList(Base, TimestampMixin):
|
||||||
__tablename__ = "addresses_address_lists"
|
__tablename__ = "addresses_address_lists"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -401,7 +560,11 @@ __all__ = [
|
|||||||
"Contact",
|
"Contact",
|
||||||
"ContactEmail",
|
"ContactEmail",
|
||||||
"ContactPhone",
|
"ContactPhone",
|
||||||
|
"ContactFieldProvenance",
|
||||||
|
"ContactMergeRecord",
|
||||||
|
"ContactPointQualityDecision",
|
||||||
"ContactPointSnapshot",
|
"ContactPointSnapshot",
|
||||||
"ContactPostalAddress",
|
"ContactPostalAddress",
|
||||||
|
"ContactRedirect",
|
||||||
"new_uuid",
|
"new_uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ from govoplan_addresses.backend.provider_state import (
|
|||||||
|
|
||||||
|
|
||||||
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
_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.ContactPointSnapshot,
|
||||||
addresses_models.AddressSyncDiagnostic,
|
addresses_models.AddressSyncDiagnostic,
|
||||||
addresses_models.AddressSyncConflict,
|
addresses_models.AddressSyncConflict,
|
||||||
@@ -154,6 +158,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
AddressList,
|
AddressList,
|
||||||
AddressSyncSource,
|
AddressSyncSource,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactMergeRecord,
|
||||||
|
ContactPointQualityDecision,
|
||||||
ContactPointSnapshot,
|
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_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(),
|
"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(),
|
"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(),
|
"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(),
|
"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.AddressSyncSource,
|
||||||
addresses_models.AddressListEntry,
|
addresses_models.AddressListEntry,
|
||||||
addresses_models.AddressList,
|
addresses_models.AddressList,
|
||||||
|
addresses_models.ContactFieldProvenance,
|
||||||
|
addresses_models.ContactRedirect,
|
||||||
|
addresses_models.ContactMergeRecord,
|
||||||
|
addresses_models.ContactPointQualityDecision,
|
||||||
addresses_models.ContactPointSnapshot,
|
addresses_models.ContactPointSnapshot,
|
||||||
addresses_models.AddressBook,
|
addresses_models.AddressBook,
|
||||||
addresses_models.Contact,
|
addresses_models.Contact,
|
||||||
@@ -328,6 +340,25 @@ manifest = ModuleManifest(
|
|||||||
related_modules=("dist_lists", "campaigns", "policy", "templates"),
|
related_modules=("dist_lists", "campaigns", "policy", "templates"),
|
||||||
order=31,
|
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_providers=(CARDDAV_PROVIDER,),
|
||||||
external_provider_state_providers=(
|
external_provider_state_providers=(
|
||||||
|
|||||||
+281
@@ -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)
|
||||||
@@ -25,6 +25,8 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
AddressSyncTombstone,
|
AddressSyncTombstone,
|
||||||
Contact,
|
Contact,
|
||||||
ContactChannelRule,
|
ContactChannelRule,
|
||||||
|
ContactMergeRecord,
|
||||||
|
ContactPointQualityDecision,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.capabilities import (
|
from govoplan_addresses.backend.capabilities import (
|
||||||
@@ -74,6 +76,18 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactChannelRuleCreateRequest,
|
ContactChannelRuleCreateRequest,
|
||||||
ContactChannelRuleListResponse,
|
ContactChannelRuleListResponse,
|
||||||
ContactChannelRuleResponse,
|
ContactChannelRuleResponse,
|
||||||
|
ContactDuplicateFeatureResponse,
|
||||||
|
ContactDuplicateSuggestionListResponse,
|
||||||
|
ContactDuplicateSuggestionResponse,
|
||||||
|
ContactFieldProvenanceResponse,
|
||||||
|
ContactMergeRecordListResponse,
|
||||||
|
ContactMergeRecordResponse,
|
||||||
|
ContactMergeRecoveryRequest,
|
||||||
|
ContactMergeRequest,
|
||||||
|
ContactPointQualityDecisionCreateRequest,
|
||||||
|
ContactPointQualityDecisionListResponse,
|
||||||
|
ContactPointQualityDecisionResponse,
|
||||||
|
ContactRedirectResponse,
|
||||||
ContactPointResolveRequest,
|
ContactPointResolveRequest,
|
||||||
ContactPointResolutionResponse,
|
ContactPointResolutionResponse,
|
||||||
ContactPointSnapshotResponse,
|
ContactPointSnapshotResponse,
|
||||||
@@ -82,6 +96,8 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactListResponse,
|
ContactListResponse,
|
||||||
ContactResponse,
|
ContactResponse,
|
||||||
ContactUpdateRequest,
|
ContactUpdateRequest,
|
||||||
|
AddressQualityCorrectionResponse,
|
||||||
|
AddressQualitySummaryResponse,
|
||||||
VCardImportIssue,
|
VCardImportIssue,
|
||||||
VCardImportRequest,
|
VCardImportRequest,
|
||||||
VCardImportResponse,
|
VCardImportResponse,
|
||||||
@@ -90,6 +106,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
AddressBookError,
|
AddressBookError,
|
||||||
available_address_credentials,
|
available_address_credentials,
|
||||||
address_book_contact_counts,
|
address_book_contact_counts,
|
||||||
|
address_quality_summary,
|
||||||
address_list_entry_counts,
|
address_list_entry_counts,
|
||||||
create_address_book,
|
create_address_book,
|
||||||
create_address_list,
|
create_address_list,
|
||||||
@@ -97,6 +114,8 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
create_contact_channel_rule,
|
create_contact_channel_rule,
|
||||||
|
create_contact_quality_decision,
|
||||||
|
current_contact_quality,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
count_contacts,
|
count_contacts,
|
||||||
delete_address_book,
|
delete_address_book,
|
||||||
@@ -115,6 +134,9 @@ from govoplan_addresses.backend.service import (
|
|||||||
list_address_books,
|
list_address_books,
|
||||||
list_contacts,
|
list_contacts,
|
||||||
list_contact_channel_rules,
|
list_contact_channel_rules,
|
||||||
|
list_contact_field_provenance,
|
||||||
|
list_contact_merges,
|
||||||
|
list_contact_quality_decisions,
|
||||||
list_sync_conflicts,
|
list_sync_conflicts,
|
||||||
list_sync_diagnostics,
|
list_sync_diagnostics,
|
||||||
list_sync_sources,
|
list_sync_sources,
|
||||||
@@ -122,9 +144,12 @@ from govoplan_addresses.backend.service import (
|
|||||||
record_sync_conflict,
|
record_sync_conflict,
|
||||||
record_sync_diagnostic,
|
record_sync_diagnostic,
|
||||||
record_sync_tombstone,
|
record_sync_tombstone,
|
||||||
|
merge_contacts,
|
||||||
|
recover_contact_merge,
|
||||||
restore_address_book,
|
restore_address_book,
|
||||||
restore_address_list,
|
restore_address_list,
|
||||||
restore_contact,
|
restore_contact,
|
||||||
|
resolve_contact_redirect,
|
||||||
resolve_sync_conflict,
|
resolve_sync_conflict,
|
||||||
preview_sync_source,
|
preview_sync_source,
|
||||||
public_address_sync_metadata,
|
public_address_sync_metadata,
|
||||||
@@ -133,6 +158,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
update_address_book,
|
update_address_book,
|
||||||
update_address_list,
|
update_address_list,
|
||||||
update_contact,
|
update_contact,
|
||||||
|
suggest_duplicate_contacts,
|
||||||
update_sync_source,
|
update_sync_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,14 +198,123 @@ def _book_response(book: AddressBook, *, contact_count: int = 0) -> AddressBookR
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _contact_response(contact: Contact) -> ContactResponse:
|
def _contact_response(
|
||||||
return ContactResponse.model_validate(contact)
|
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:
|
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
|
||||||
return ContactChannelRuleResponse.model_validate(rule)
|
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:
|
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
|
||||||
return AddressListResponse.model_validate(
|
return AddressListResponse.model_validate(
|
||||||
{
|
{
|
||||||
@@ -543,6 +678,339 @@ def api_lookup_addresses(
|
|||||||
return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts])
|
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)
|
@router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse)
|
||||||
def api_resolve_contact_points(
|
def api_resolve_contact_points(
|
||||||
payload: ContactPointResolveRequest,
|
payload: ContactPointResolveRequest,
|
||||||
@@ -774,6 +1242,19 @@ def api_create_address_list_entry(
|
|||||||
_require_scope(principal, "addresses:address_list:write")
|
_require_scope(principal, "addresses:address_list:write")
|
||||||
try:
|
try:
|
||||||
entry = create_address_list_entry(session, principal, address_list_id, payload)
|
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.commit()
|
||||||
session.refresh(entry)
|
session.refresh(entry)
|
||||||
return _address_list_entry_response(entry)
|
return _address_list_entry_response(entry)
|
||||||
@@ -790,7 +1271,20 @@ def api_delete_address_list_entry(
|
|||||||
):
|
):
|
||||||
_require_scope(principal, "addresses:address_list:write")
|
_require_scope(principal, "addresses:address_list:write")
|
||||||
try:
|
try:
|
||||||
|
entry = session.get(AddressListEntry, entry_id)
|
||||||
delete_address_list_entry(session, principal, 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()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except AddressBookError as exc:
|
except AddressBookError as exc:
|
||||||
@@ -1234,6 +1728,19 @@ def api_create_contact(
|
|||||||
_require_scope(principal, "addresses:contact:write")
|
_require_scope(principal, "addresses:contact:write")
|
||||||
try:
|
try:
|
||||||
contact = create_contact(session, principal, book_id, payload)
|
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.commit()
|
||||||
session.refresh(contact)
|
session.refresh(contact)
|
||||||
return _contact_response(contact)
|
return _contact_response(contact)
|
||||||
@@ -1251,7 +1758,27 @@ def api_update_contact(
|
|||||||
):
|
):
|
||||||
_require_scope(principal, "addresses:contact:write")
|
_require_scope(principal, "addresses:contact:write")
|
||||||
try:
|
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)
|
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.commit()
|
||||||
session.refresh(contact)
|
session.refresh(contact)
|
||||||
return _contact_response(contact)
|
return _contact_response(contact)
|
||||||
@@ -1360,6 +1887,18 @@ def api_delete_contact(
|
|||||||
_require_scope(principal, "addresses:contact:delete")
|
_require_scope(principal, "addresses:contact:delete")
|
||||||
try:
|
try:
|
||||||
delete_contact(session, principal, contact_id)
|
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()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except AddressBookError as exc:
|
except AddressBookError as exc:
|
||||||
@@ -1376,6 +1915,17 @@ def api_restore_contact(
|
|||||||
_require_scope(principal, "addresses:contact:write")
|
_require_scope(principal, "addresses:contact:write")
|
||||||
try:
|
try:
|
||||||
contact = restore_contact(session, principal, contact_id)
|
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.commit()
|
||||||
session.refresh(contact)
|
session.refresh(contact)
|
||||||
return _contact_response(contact)
|
return _contact_response(contact)
|
||||||
@@ -1394,6 +1944,20 @@ def api_import_address_book_vcards(
|
|||||||
_require_scope(principal, "addresses:contact:write")
|
_require_scope(principal, "addresses:contact:write")
|
||||||
try:
|
try:
|
||||||
result = import_vcards(session, principal, book_id, payload.content)
|
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()
|
session.commit()
|
||||||
for contact in result.contacts:
|
for contact in result.contacts:
|
||||||
session.refresh(contact)
|
session.refresh(contact)
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "ma
|
|||||||
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
||||||
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
||||||
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
||||||
|
AddressContactPointChannel = Literal[
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"postal",
|
||||||
|
"internal_mail",
|
||||||
|
"portal",
|
||||||
|
]
|
||||||
AddressChannelDecision = Literal[
|
AddressChannelDecision = Literal[
|
||||||
"allowed",
|
"allowed",
|
||||||
"opted_in",
|
"opted_in",
|
||||||
@@ -38,6 +45,13 @@ AddressDistributionOutcome = Literal[
|
|||||||
]
|
]
|
||||||
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
|
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||||
AddressPostalFormat = Literal["domestic", "international"]
|
AddressPostalFormat = Literal["domestic", "international"]
|
||||||
|
ContactPointQualityState = Literal[
|
||||||
|
"valid",
|
||||||
|
"invalid",
|
||||||
|
"returned",
|
||||||
|
"stale",
|
||||||
|
"undeliverable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ContactEmailPayload(BaseModel):
|
class ContactEmailPayload(BaseModel):
|
||||||
@@ -186,12 +200,75 @@ class ContactUpdateRequest(BaseModel):
|
|||||||
provenance: dict[str, Any] | None = None
|
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):
|
class ContactEmailResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
email: str
|
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
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +278,11 @@ class ContactPhoneResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
phone: str
|
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
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +296,11 @@ class ContactPostalAddressResponse(BaseModel):
|
|||||||
locality: str | None = None
|
locality: str | None = None
|
||||||
region: str | None = None
|
region: str | None = None
|
||||||
country: 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
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +325,7 @@ class ContactResponse(BaseModel):
|
|||||||
emails: list[ContactEmailResponse]
|
emails: list[ContactEmailResponse]
|
||||||
phones: list[ContactPhoneResponse]
|
phones: list[ContactPhoneResponse]
|
||||||
postal_addresses: list[ContactPostalAddressResponse]
|
postal_addresses: list[ContactPostalAddressResponse]
|
||||||
|
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
|
||||||
deleted_at: datetime | None = None
|
deleted_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -251,6 +339,103 @@ class ContactListResponse(BaseModel):
|
|||||||
has_more: bool
|
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):
|
class ContactChannelRuleCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,9 +51,13 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
Contact,
|
Contact,
|
||||||
ContactChannelRule,
|
ContactChannelRule,
|
||||||
ContactEmail,
|
ContactEmail,
|
||||||
|
ContactFieldProvenance,
|
||||||
|
ContactMergeRecord,
|
||||||
ContactPhone,
|
ContactPhone,
|
||||||
ContactPointSnapshot,
|
ContactPointSnapshot,
|
||||||
|
ContactPointQualityDecision,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
|
ContactRedirect,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.schemas import (
|
from govoplan_addresses.backend.schemas import (
|
||||||
AddressBookCreateRequest,
|
AddressBookCreateRequest,
|
||||||
@@ -71,13 +75,27 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactCreateRequest,
|
ContactCreateRequest,
|
||||||
ContactChannelRuleCreateRequest,
|
ContactChannelRuleCreateRequest,
|
||||||
ContactEmailPayload,
|
ContactEmailPayload,
|
||||||
|
ContactMergeRecoveryRequest,
|
||||||
|
ContactMergeRequest,
|
||||||
|
ContactPhonePayload,
|
||||||
|
ContactPointQualityDecisionCreateRequest,
|
||||||
ContactPostalAddressPayload,
|
ContactPostalAddressPayload,
|
||||||
ContactPointSnapshotResponse,
|
ContactPointSnapshotResponse,
|
||||||
|
ContactUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.manifest import manifest
|
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 (
|
from govoplan_addresses.backend.service import (
|
||||||
AddressBookError,
|
AddressBookError,
|
||||||
|
address_quality_summary,
|
||||||
address_book_contact_counts,
|
address_book_contact_counts,
|
||||||
address_list_entry_counts,
|
address_list_entry_counts,
|
||||||
create_address_book,
|
create_address_book,
|
||||||
@@ -86,6 +104,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
create_contact_channel_rule,
|
create_contact_channel_rule,
|
||||||
|
create_contact_quality_decision,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
count_contacts,
|
count_contacts,
|
||||||
delete_address_list_entry,
|
delete_address_list_entry,
|
||||||
@@ -100,6 +119,8 @@ from govoplan_addresses.backend.service import (
|
|||||||
list_address_books,
|
list_address_books,
|
||||||
list_contacts,
|
list_contacts,
|
||||||
list_contact_channel_rules,
|
list_contact_channel_rules,
|
||||||
|
list_contact_field_provenance,
|
||||||
|
list_contact_merges,
|
||||||
list_sync_conflicts,
|
list_sync_conflicts,
|
||||||
list_sync_diagnostics,
|
list_sync_diagnostics,
|
||||||
list_sync_sources,
|
list_sync_sources,
|
||||||
@@ -109,11 +130,16 @@ from govoplan_addresses.backend.service import (
|
|||||||
record_sync_tombstone,
|
record_sync_tombstone,
|
||||||
run_sync_source,
|
run_sync_source,
|
||||||
preview_sync_source,
|
preview_sync_source,
|
||||||
|
merge_contacts,
|
||||||
|
recover_contact_merge,
|
||||||
restore_contact,
|
restore_contact,
|
||||||
|
resolve_contact_redirect,
|
||||||
resolve_sync_conflict,
|
resolve_sync_conflict,
|
||||||
start_sync_attempt,
|
start_sync_attempt,
|
||||||
finish_sync_attempt,
|
finish_sync_attempt,
|
||||||
update_sync_source,
|
update_sync_source,
|
||||||
|
update_contact,
|
||||||
|
suggest_duplicate_contacts,
|
||||||
resolve_trusted_deployment_carddav_credential_ref,
|
resolve_trusted_deployment_carddav_credential_ref,
|
||||||
_carddav_client_for_source,
|
_carddav_client_for_source,
|
||||||
)
|
)
|
||||||
@@ -207,6 +233,10 @@ class AddressServiceTest(unittest.TestCase):
|
|||||||
ContactPostalAddress.__table__,
|
ContactPostalAddress.__table__,
|
||||||
ContactChannelRule.__table__,
|
ContactChannelRule.__table__,
|
||||||
ContactPointSnapshot.__table__,
|
ContactPointSnapshot.__table__,
|
||||||
|
ContactPointQualityDecision.__table__,
|
||||||
|
ContactMergeRecord.__table__,
|
||||||
|
ContactRedirect.__table__,
|
||||||
|
ContactFieldProvenance.__table__,
|
||||||
AddressListEntry.__table__,
|
AddressListEntry.__table__,
|
||||||
AddressSyncSource.__table__,
|
AddressSyncSource.__table__,
|
||||||
AddressSyncTombstone.__table__,
|
AddressSyncTombstone.__table__,
|
||||||
@@ -279,6 +309,75 @@ class AddressServiceTest(unittest.TestCase):
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
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:
|
def test_contact_windows_report_exact_totals(self) -> None:
|
||||||
book = create_address_book(
|
book = create_address_book(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -605,6 +704,334 @@ END:VCARD
|
|||||||
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
|
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)
|
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:
|
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"))
|
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"))
|
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export type ContactEmail = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
|
original_email?: string;
|
||||||
|
normalized_email?: string;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
quality_state?: ContactPointQualityState;
|
||||||
|
quality_reason_code?: string | null;
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +51,11 @@ export type ContactPhone = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
original_phone?: string;
|
||||||
|
normalized_phone?: string;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
quality_state?: ContactPointQualityState;
|
||||||
|
quality_reason_code?: string | null;
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,9 +67,35 @@ export type ContactPostalAddress = {
|
|||||||
locality?: string | null;
|
locality?: string | null;
|
||||||
region?: string | null;
|
region?: string | null;
|
||||||
country?: 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;
|
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 = {
|
export type Contact = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
@@ -79,11 +115,95 @@ export type Contact = {
|
|||||||
emails: ContactEmail[];
|
emails: ContactEmail[];
|
||||||
phones: ContactPhone[];
|
phones: ContactPhone[];
|
||||||
postal_addresses: ContactPostalAddress[];
|
postal_addresses: ContactPostalAddress[];
|
||||||
|
field_provenance?: ContactFieldProvenance[];
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||||
export type AddressChannelDecision =
|
export type AddressChannelDecision =
|
||||||
| "allowed"
|
| "allowed"
|
||||||
@@ -392,6 +512,14 @@ type ContactChannelRuleListResponse = {
|
|||||||
rules: ContactChannelRule[];
|
rules: ContactChannelRule[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContactPointQualityDecisionListResponse = {
|
||||||
|
decisions: ContactPointQualityDecision[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactMergeRecordListResponse = {
|
||||||
|
merges: ContactMergeRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
function queryString(params: Record<string, string | number | null | undefined>): string {
|
function queryString(params: Record<string, string | number | null | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
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" });
|
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[]> {
|
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
|
||||||
const response = await apiFetch<ContactChannelRuleListResponse>(
|
const response = await apiFetch<ContactChannelRuleListResponse>(
|
||||||
settings,
|
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 { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
formatDateTime,
|
formatDateTime,
|
||||||
FormField,
|
FormField,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
MetricCard,
|
||||||
PasswordField,
|
PasswordField,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SelectionList,
|
SelectionList,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
createCardDavSyncSource,
|
createCardDavSyncSource,
|
||||||
createContact,
|
createContact,
|
||||||
createContactChannelRule,
|
createContactChannelRule,
|
||||||
|
createContactQualityDecision,
|
||||||
deleteAddressBook,
|
deleteAddressBook,
|
||||||
deleteAddressList,
|
deleteAddressList,
|
||||||
deleteAddressListEntry,
|
deleteAddressListEntry,
|
||||||
@@ -41,6 +43,7 @@ import {
|
|||||||
exportAddressBookVcards,
|
exportAddressBookVcards,
|
||||||
exportContactVcard,
|
exportContactVcard,
|
||||||
importAddressBookVcards,
|
importAddressBookVcards,
|
||||||
|
getAddressQualitySummary,
|
||||||
listAddressBooks,
|
listAddressBooks,
|
||||||
listAddressCredentials,
|
listAddressCredentials,
|
||||||
listAddressListEntries,
|
listAddressListEntries,
|
||||||
@@ -52,7 +55,12 @@ import {
|
|||||||
listContacts,
|
listContacts,
|
||||||
listContactsPage,
|
listContactsPage,
|
||||||
listContactChannelRules,
|
listContactChannelRules,
|
||||||
|
listContactDuplicateSuggestions,
|
||||||
|
listContactMerges,
|
||||||
|
listContactProvenance,
|
||||||
previewAddressSyncSource,
|
previewAddressSyncSource,
|
||||||
|
mergeContacts,
|
||||||
|
recoverContactMerge,
|
||||||
restoreAddressBook,
|
restoreAddressBook,
|
||||||
restoreAddressList,
|
restoreAddressList,
|
||||||
restoreContact,
|
restoreContact,
|
||||||
@@ -75,9 +83,15 @@ import {
|
|||||||
type AddressSyncPlan,
|
type AddressSyncPlan,
|
||||||
type AddressSyncSource,
|
type AddressSyncSource,
|
||||||
type AddressSyncTombstone,
|
type AddressSyncTombstone,
|
||||||
|
type AddressQualitySummary,
|
||||||
type Contact,
|
type Contact,
|
||||||
type ContactChannelRule,
|
type ContactChannelRule,
|
||||||
type ContactChannelRulePayload
|
type ContactChannelRulePayload,
|
||||||
|
type ContactDuplicateSuggestion,
|
||||||
|
type ContactFieldProvenance,
|
||||||
|
type ContactMergeRecord,
|
||||||
|
type ContactPointQualityDecision,
|
||||||
|
type ContactPointQualityState
|
||||||
} from "../../api/addresses";
|
} from "../../api/addresses";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -210,6 +224,65 @@ type AddressTreeNode = {
|
|||||||
|
|
||||||
type ConflictMergeChoice = "local" | "remote";
|
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 = {
|
const EMPTY_BOOK_FORM: BookFormState = {
|
||||||
scope_type: "user",
|
scope_type: "user",
|
||||||
group_id: "",
|
group_id: "",
|
||||||
@@ -247,6 +320,13 @@ const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
|
|||||||
effective_until: ""
|
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 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;
|
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 ?? "";
|
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" {
|
function sourceGroupForBook(book: AddressBook): "local" | "linked" {
|
||||||
return book.source_kind === "local" ? "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 [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
|
||||||
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
|
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
|
||||||
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
|
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 [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
||||||
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
||||||
const [memberQuery, setMemberQuery] = useState("");
|
const [memberQuery, setMemberQuery] = useState("");
|
||||||
@@ -853,6 +974,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
[!canWriteSync, "You need permission to run address sync."],
|
[!canWriteSync, "You need permission to run address sync."],
|
||||||
[saving, savingReason]
|
[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(
|
const createContactReason = disabledReason(
|
||||||
[!selectedBook, "Select an address book before adding a contact."],
|
[!selectedBook, "Select an address book before adding a contact."],
|
||||||
[!canWriteContacts, "You need permission to manage contacts."],
|
[!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">>) {
|
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
|
||||||
setContactForm((current) => ({
|
setContactForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1439,8 +1725,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
tags: tagsFromForm(contactForm.tags),
|
tags: tagsFromForm(contactForm.tags),
|
||||||
emails,
|
emails,
|
||||||
phones,
|
phones,
|
||||||
postal_addresses,
|
postal_addresses
|
||||||
provenance: {}
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let savedContact: Contact;
|
let savedContact: Contact;
|
||||||
@@ -1973,6 +2258,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
return (
|
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="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 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="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>
|
<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)}>
|
onClick={() => void openGovernanceDialog(selectedContact)}>
|
||||||
<ShieldCheck size={15} /> Governance
|
<ShieldCheck size={15} /> Governance
|
||||||
</Button>
|
</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="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" 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>
|
<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) => (
|
{selectedContact.emails.map((email, index) => (
|
||||||
<div key={email.id ?? `${email.email}-${index}`}>
|
<div key={email.id ?? `${email.email}-${index}`}>
|
||||||
<dt>{email.label || "email"}{email.is_primary ? " · primary" : ""}</dt>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -2102,7 +2418,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedContact.phones.map((phone, index) => (
|
{selectedContact.phones.map((phone, index) => (
|
||||||
<div key={phone.id ?? `${phone.phone}-${index}`}>
|
<div key={phone.id ?? `${phone.phone}-${index}`}>
|
||||||
<dt>{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}</dt>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -2115,7 +2453,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedContact.postal_addresses.map((address, index) => (
|
{selectedContact.postal_addresses.map((address, index) => (
|
||||||
<div key={address.id ?? `${address.label}-${index}`}>
|
<div key={address.id ?? `${address.label}-${index}`}>
|
||||||
<dt>{address.label || "address"}{address.is_primary ? " · primary" : ""}</dt>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -2507,6 +2867,258 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={memberDialogOpen}
|
open={memberDialogOpen}
|
||||||
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
||||||
|
|||||||
@@ -420,6 +420,23 @@
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.address-membership-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -543,6 +560,104 @@
|
|||||||
width: min(980px, calc(100vw - 32px));
|
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-layout,
|
||||||
.address-governance-list {
|
.address-governance-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -637,4 +752,13 @@
|
|||||||
.address-form-row-postal {
|
.address-form-row-postal {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-quality-row,
|
||||||
|
.address-contact-point-value {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-point-value > small {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user