Add governed contact channel facts

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 90a507d9a4
commit 41ccd4c807
11 changed files with 1364 additions and 14 deletions
+76
View File
@@ -84,6 +84,53 @@ export type Contact = {
updated_at: string;
};
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
export type AddressChannelDecision =
| "allowed"
| "opted_in"
| "preferred"
| "opted_out"
| "suppressed"
| "invalid"
| "returned"
| "temporarily_unavailable";
export type ContactChannelRule = {
id: string;
tenant_id?: string | null;
contact_id: string;
channel: AddressDistributionChannel;
purpose?: string | null;
contact_point_id?: string | null;
decision: AddressChannelDecision;
legal_basis?: string | null;
evidence_ref?: string | null;
reason?: string | null;
preference_rank?: number | null;
locale?: string | null;
effective_from?: string | null;
effective_until?: string | null;
metadata: Record<string, unknown>;
created_by_account_id?: string | null;
created_at: string;
updated_at: string;
};
export type ContactChannelRulePayload = {
channel: AddressDistributionChannel;
purpose?: string | null;
contact_point_id?: string | null;
decision: AddressChannelDecision;
legal_basis?: string | null;
evidence_ref?: string | null;
reason?: string | null;
preference_rank?: number | null;
locale?: string | null;
effective_from?: string | null;
effective_until?: string | null;
metadata?: Record<string, unknown>;
};
export type AddressBookCreatePayload = {
scope_type: AddressBookScope;
group_id?: string | null;
@@ -341,6 +388,10 @@ type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[];
};
type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -626,6 +677,31 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
}
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
const response = await apiFetch<ContactChannelRuleListResponse>(
settings,
`/api/v1/addresses/contacts/${contactId}/channel-rules`
);
return response.rules;
}
export function createContactChannelRule(
settings: ApiSettings,
contactId: string,
payload: ContactChannelRulePayload
): Promise<ContactChannelRule> {
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contacts/${contactId}/channel-rules`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function endContactChannelRule(settings: ApiSettings, ruleId: string): Promise<ContactChannelRule> {
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contact-channel-rules/${ruleId}`, {
method: "DELETE"
});
}
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
method: "POST",
@@ -1,4 +1,4 @@
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import {
ApiError,
@@ -30,12 +30,14 @@ import {
createAddressListEntry,
createCardDavSyncSource,
createContact,
createContactChannelRule,
deleteAddressBook,
deleteAddressList,
deleteAddressListEntry,
deleteContact,
deleteAddressSyncSource,
discoverCardDavAddressBooks,
endContactChannelRule,
exportAddressBookVcards,
exportContactVcard,
importAddressBookVcards,
@@ -49,6 +51,7 @@ import {
listAddressSyncTombstones,
listContacts,
listContactsPage,
listContactChannelRules,
previewAddressSyncSource,
restoreAddressBook,
restoreAddressList,
@@ -62,7 +65,9 @@ import {
type AddressCardDavAddressBook,
type AddressBook,
type AddressBookScope,
type AddressChannelDecision,
type AddressCredentialEnvelope,
type AddressDistributionChannel,
type AddressList,
type AddressListEntry,
type AddressSyncConflict,
@@ -70,7 +75,9 @@ import {
type AddressSyncPlan,
type AddressSyncSource,
type AddressSyncTombstone,
type Contact
type Contact,
type ContactChannelRule,
type ContactChannelRulePayload
} from "../../api/addresses";
type Props = {
@@ -139,6 +146,20 @@ type ContactFormState = {
note: string;
};
type ChannelRuleFormState = {
channel: AddressDistributionChannel;
purpose: string;
contact_point_id: string;
decision: AddressChannelDecision;
legal_basis: string;
evidence_ref: string;
reason: string;
preference_rank: string;
locale: string;
effective_from: string;
effective_until: string;
};
type ListFormState = {
name: string;
description: string;
@@ -212,6 +233,20 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
sync_direction: "read_only"
};
const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
channel: "email",
purpose: "",
contact_point_id: "",
decision: "allowed",
legal_basis: "",
evidence_ref: "",
reason: "",
preference_rank: "",
locale: "",
effective_from: "",
effective_until: ""
};
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;
@@ -613,6 +648,13 @@ function downloadText(filename: string, content: string, type = "text/vcard;char
window.URL.revokeObjectURL(url);
}
function channelRuleState(rule: ContactChannelRule): "active" | "scheduled" | "ended" {
const now = Date.now();
if (rule.effective_until && new Date(rule.effective_until).getTime() <= now) return "ended";
if (rule.effective_from && new Date(rule.effective_from).getTime() > now) return "scheduled";
return "active";
}
function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? "";
}
@@ -642,6 +684,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
const [memberQuery, setMemberQuery] = useState("");
@@ -670,6 +715,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const canDeleteLists = hasScope(auth, "addresses:address_list:delete");
const canWriteContacts = hasScope(auth, "addresses:contact:write");
const canDeleteContacts = hasScope(auth, "addresses:contact:delete");
const canReadGovernance = hasScope(auth, "addresses:governance:read");
const canWriteGovernance = hasScope(auth, "addresses:governance:write");
const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write");
@@ -744,6 +791,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
});
}, [memberCandidates, memberQuery, selectedListContactIds]);
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null;
const governancePointOptions = useMemo(() => {
if (!governanceContact) return [];
if (channelRuleForm.channel === "email") {
return governanceContact.emails
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
.map((item) => ({ id: item.id, label: `${item.label || "email"}: ${item.email}` }));
}
if (channelRuleForm.channel === "postal") {
return governanceContact.postal_addresses
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
.map((item) => ({ id: item.id, label: `${item.label || "address"}: ${formatPostalAddress(item) || "Unformatted address"}` }));
}
return [];
}, [channelRuleForm.channel, governanceContact]);
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
const loadingReason = loading ? "Address books are loading." : "";
@@ -827,6 +888,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason],
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."]
);
const channelRuleSaveReason = disabledReason(
[saving, savingReason],
[!governanceContact, "Select a contact before recording a governance fact."],
[!canWriteGovernance, "You need permission to manage communication governance."],
[
Boolean(
channelRuleForm.effective_from
&& channelRuleForm.effective_until
&& new Date(channelRuleForm.effective_until) <= new Date(channelRuleForm.effective_from)
),
"The end of the effective period must be after its start."
]
);
const dialogCancelReason = disabledReason([saving, savingReason]);
const vcardImportReason = disabledReason(
[saving, savingReason],
@@ -1121,6 +1195,71 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setContactDialog({ mode: "edit", contact });
}
async function openGovernanceDialog(contact: Contact) {
setGovernanceContact(contact);
setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM);
setChannelRules([]);
setSaving(true);
setError("");
try {
setChannelRules(await listContactChannelRules(settings, contact.id));
} catch (err) {
setError(errorMessage(err));
setGovernanceContact(null);
} finally {
setSaving(false);
}
}
async function submitChannelRule(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!governanceContact || channelRuleSaveReason) return;
const toIso = (value: string) => value ? new Date(value).toISOString() : null;
const payload: ContactChannelRulePayload = {
channel: channelRuleForm.channel,
purpose: channelRuleForm.purpose.trim() || null,
contact_point_id: channelRuleForm.contact_point_id || null,
decision: channelRuleForm.decision,
legal_basis: channelRuleForm.legal_basis.trim() || null,
evidence_ref: channelRuleForm.evidence_ref.trim() || null,
reason: channelRuleForm.reason.trim() || null,
preference_rank: channelRuleForm.preference_rank ? Number(channelRuleForm.preference_rank) : null,
locale: channelRuleForm.locale.trim() || null,
effective_from: toIso(channelRuleForm.effective_from),
effective_until: toIso(channelRuleForm.effective_until),
metadata: {}
};
setSaving(true);
setError("");
setNotice("");
try {
await createContactChannelRule(settings, governanceContact.id, payload);
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
setChannelRuleForm((current) => ({ ...EMPTY_CHANNEL_RULE_FORM, channel: current.channel }));
setNotice("Communication-governance fact recorded.");
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function endChannelRule(rule: ContactChannelRule) {
if (!governanceContact || saving || !canWriteGovernance) return;
setSaving(true);
setError("");
setNotice("");
try {
await endContactChannelRule(settings, rule.id);
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
setNotice("Communication-governance fact ended; its history was retained.");
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
setContactForm((current) => ({
...current,
@@ -1901,6 +2040,17 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContact.deleted_at ?
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
<>
<Button
type="button"
title="Communication governance"
aria-label="Communication governance"
disabledReason={disabledReason(
[!canReadGovernance, "You need permission to view communication governance."],
[saving, savingReason]
)}
onClick={() => void openGovernanceDialog(selectedContact)}>
<ShieldCheck size={15} /> Governance
</Button>
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
@@ -2235,6 +2385,128 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</form>
</Dialog>
<Dialog
open={Boolean(governanceContact)}
title={governanceContact ? `Communication governance · ${governanceContact.display_name}` : "Communication governance"}
onClose={() => setGovernanceContact(null)}
closeDisabled={saving}
className="address-governance-dialog"
footerClassName="button-row compact-actions"
footer={<Button type="button" onClick={() => setGovernanceContact(null)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-governance-layout">
<section className="address-form-section">
<div className="address-form-section-heading">
<div>
<strong>Recorded facts</strong>
<p className="muted small-text">History is retained. End a fact when it no longer applies.</p>
</div>
</div>
<div className="address-governance-list">
{channelRules.length === 0 ?
<p className="muted">No communication-governance facts recorded.</p> :
channelRules.map((rule) => {
const state = channelRuleState(rule);
return (
<article className="address-governance-rule" key={rule.id}>
<div className="address-governance-rule-main">
<span className="address-governance-rule-heading">
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")}</strong>
<StatusBadge status={state} />
</span>
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
<small>
{rule.legal_basis ? `Basis: ${rule.legal_basis}` : "No legal basis recorded"}
{rule.evidence_ref ? ` · Evidence: ${rule.evidence_ref}` : ""}
{rule.effective_from ? ` · From ${formatDateTime(rule.effective_from, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
{rule.effective_until ? ` · Until ${formatDateTime(rule.effective_until, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
</small>
</div>
{state !== "ended" &&
<Button
type="button"
variant="danger"
disabledReason={disabledReason(
[!canWriteGovernance, "You need permission to manage communication governance."],
[saving, savingReason]
)}
onClick={() => void endChannelRule(rule)}>
<X size={15} /> End
</Button>
}
</article>
);
})
}
</div>
</section>
{canWriteGovernance &&
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
<div className="address-form-section-heading">
<div>
<strong>Record a fact</strong>
<p className="muted small-text">Addresses records evidence; Policy may apply stricter delivery rules.</p>
</div>
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
</div>
<div className="form-grid two">
<FormField label="Channel">
<select
value={channelRuleForm.channel}
onChange={(event) => setChannelRuleForm((current) => ({
...current,
channel: event.target.value as AddressDistributionChannel,
contact_point_id: ""
}))}>
<option value="email">Email</option>
<option value="postal">Postal mail</option>
<option value="internal_mail">Internal mail</option>
<option value="portal">Portal</option>
</select>
</FormField>
<FormField label="Contact point">
<select
value={channelRuleForm.contact_point_id}
disabled={governancePointOptions.length === 0}
onChange={(event) => setChannelRuleForm((current) => ({ ...current, contact_point_id: event.target.value }))}>
<option value="">All {channelRuleForm.channel.replace("_", " ")} contact points</option>
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
</select>
</FormField>
</div>
<div className="form-grid two">
<FormField label="Decision">
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
<option value="allowed">Allowed</option>
<option value="opted_in">Opted in</option>
<option value="preferred">Preferred</option>
<option value="opted_out">Opted out</option>
<option value="suppressed">Suppressed</option>
<option value="invalid">Invalid</option>
<option value="returned">Returned</option>
<option value="temporarily_unavailable">Temporarily unavailable</option>
</select>
</FormField>
<FormField label="Communication purpose">
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
</FormField>
</div>
<div className="form-grid three">
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
</div>
<div className="form-grid three">
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
</div>
<FormField label="Reason"><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
</form>
}
</div>
</Dialog>
<Dialog
open={memberDialogOpen}
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
+45
View File
@@ -539,6 +539,51 @@
gap: 8px;
}
.address-governance-dialog .dialog-panel {
width: min(980px, calc(100vw - 32px));
}
.address-governance-layout,
.address-governance-list {
display: grid;
gap: 12px;
}
.address-governance-list {
max-height: 260px;
overflow: auto;
}
.address-governance-rule {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px 0;
}
.address-governance-rule:last-child {
border-bottom: 0;
}
.address-governance-rule-main,
.address-governance-rule-heading {
display: flex;
gap: 6px;
}
.address-governance-rule-main {
align-items: flex-start;
flex-direction: column;
min-width: 0;
}
.address-governance-rule-heading {
align-items: center;
flex-wrap: wrap;
}
.address-form-row-email,
.address-form-row-phone {
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;