Implement address quality and reversible contact merges

This commit is contained in:
2026-08-02 07:03:27 +02:00
parent 2e78b9ae50
commit 19e9096572
15 changed files with 4369 additions and 31 deletions
+232
View File
@@ -39,6 +39,11 @@ export type ContactEmail = {
id?: string;
label?: string | null;
email: string;
original_email?: string;
normalized_email?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
@@ -46,6 +51,11 @@ export type ContactPhone = {
id?: string;
label?: string | null;
phone: string;
original_phone?: string;
normalized_phone?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
@@ -57,9 +67,35 @@ export type ContactPostalAddress = {
locality?: string | null;
region?: string | null;
country?: string | null;
original_value?: Record<string, unknown>;
normalized_value?: Record<string, unknown>;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean;
};
export type ContactPointQualityState = "valid" | "invalid" | "returned" | "stale" | "undeliverable";
export type ContactFieldProvenance = {
id: string;
contact_id: string;
field_path: string;
value?: unknown;
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
precedence: number;
selected: boolean;
reason_code: string;
explanation?: string | null;
visibility: "inherit" | "private" | "restricted" | "public";
merge_record_id?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
};
export type Contact = {
id: string;
tenant_id?: string | null;
@@ -79,11 +115,95 @@ export type Contact = {
emails: ContactEmail[];
phones: ContactPhone[];
postal_addresses: ContactPostalAddress[];
field_provenance?: ContactFieldProvenance[];
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type ContactPointQualityDecision = {
id: string;
tenant_id?: string | null;
contact_id: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
evidence_ref?: string | null;
effective_from: string;
effective_until?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type ContactDuplicateFeature = {
code: string;
label: string;
weight: number;
value: string;
};
export type ContactDuplicateSuggestion = {
left: Contact;
right: Contact;
score: number;
confidence: "possible" | "likely" | "strong";
features: ContactDuplicateFeature[];
};
export type ContactDuplicateSuggestionList = {
suggestions: ContactDuplicateSuggestion[];
scanned_contacts: number;
candidate_pairs: number;
truncated: boolean;
};
export type ContactMergeRecord = {
id: string;
tenant_id?: string | null;
address_book_id: string;
winner_contact_id: string;
loser_contact_ids: string[];
status: string;
reason: string;
survivorship: Record<string, unknown>;
decisions: Array<Record<string, unknown>>;
before_hash: string;
after_hash: string;
created_by_account_id?: string | null;
recovered_at?: string | null;
recovered_by_account_id?: string | null;
recovery_action?: string | null;
recovery_reason?: string | null;
provenance: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressQualityCorrection = {
contact_id: string;
display_name: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
effective_from: string;
};
export type AddressQualitySummary = {
contact_count: number;
contact_point_count: number;
quality_counts: Record<string, number>;
duplicate_suggestion_count: number;
correction_count: number;
corrections: AddressQualityCorrection[];
truncated: boolean;
};
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
export type AddressChannelDecision =
| "allowed"
@@ -392,6 +512,14 @@ type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
type ContactPointQualityDecisionListResponse = {
decisions: ContactPointQualityDecision[];
};
type ContactMergeRecordListResponse = {
merges: ContactMergeRecord[];
};
function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -677,6 +805,110 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
}
export function getAddressQualitySummary(settings: ApiSettings, addressBookId: string): Promise<AddressQualitySummary> {
return apiFetch<AddressQualitySummary>(settings, `/api/v1/addresses/address-books/${addressBookId}/quality-summary`);
}
export function listContactDuplicateSuggestions(
settings: ApiSettings,
addressBookId: string,
options: { contactId?: string | null; minimumScore?: number; limit?: number; scanLimit?: number } = {}
): Promise<ContactDuplicateSuggestionList> {
return apiFetch<ContactDuplicateSuggestionList>(
settings,
`/api/v1/addresses/address-books/${addressBookId}/duplicate-suggestions${queryString({
contact_id: options.contactId,
minimum_score: options.minimumScore,
limit: options.limit,
scan_limit: options.scanLimit
})}`
);
}
export async function listContactQualityDecisions(settings: ApiSettings, contactId: string): Promise<ContactPointQualityDecision[]> {
const response = await apiFetch<ContactPointQualityDecisionListResponse>(
settings,
`/api/v1/addresses/contacts/${contactId}/quality-decisions`
);
return response.decisions;
}
export function createContactQualityDecision(
settings: ApiSettings,
contactId: string,
payload: {
channel: ContactPointQualityDecision["channel"];
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code?: string | null;
reason?: string | null;
evidence_ref?: string | null;
}
): Promise<ContactPointQualityDecision> {
return apiFetch<ContactPointQualityDecision>(settings, `/api/v1/addresses/contacts/${contactId}/quality-decisions`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listContactProvenance(
settings: ApiSettings,
contactId: string,
options: { currentOnly?: boolean; limit?: number } = {}
): Promise<ContactFieldProvenance[]> {
return apiFetch<ContactFieldProvenance[]>(
settings,
`/api/v1/addresses/contacts/${contactId}/provenance${queryString({
current_only: options.currentOnly ? "true" : null,
limit: options.limit
})}`
);
}
export async function listContactMerges(
settings: ApiSettings,
options: { addressBookId?: string | null; contactId?: string | null; limit?: number } = {}
): Promise<ContactMergeRecord[]> {
const response = await apiFetch<ContactMergeRecordListResponse>(
settings,
`/api/v1/addresses/contact-merges${queryString({
address_book_id: options.addressBookId,
contact_id: options.contactId,
limit: options.limit
})}`
);
return response.merges;
}
export function mergeContacts(
settings: ApiSettings,
payload: {
winner_contact_id: string;
duplicate_contact_ids: string[];
reason: string;
field_sources?: Record<string, string>;
contact_point_strategy?: "union" | "winner_only";
source_precedence?: string[];
}
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, "/api/v1/addresses/contact-merges", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function recoverContactMerge(
settings: ApiSettings,
merge: ContactMergeRecord,
action: "undo" | "split",
reason: string
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, `/api/v1/addresses/contact-merges/${merge.id}/${action}`, {
method: "POST",
body: JSON.stringify({ reason, expected_after_hash: merge.after_hash })
});
}
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
const response = await apiFetch<ContactChannelRuleListResponse>(
settings,