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
+488 -10
View File
@@ -1,25 +1,44 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import UTC, datetime
import hashlib
import json
from typing import Any from typing import Any
from sqlalchemy import func from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.distribution_lists import (
DistributionChannelCandidate,
DistributionExplanation,
DistributionSourceReference,
RecipientChannelFacts,
RecipientChannelFactsRequest,
)
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_core.core.people import ( from govoplan_core.core.people import (
PeopleSearchGroup, PeopleSearchGroup,
PersonSearchCandidate, PersonSearchCandidate,
person_selection_key, person_selection_key,
) )
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressListEntry,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPostalAddress,
)
from govoplan_addresses.backend.schemas import ContactCreateRequest 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,
get_visible_address_book, get_visible_address_book,
get_visible_address_list, get_visible_address_list,
get_visible_contact,
list_address_books, list_address_books,
list_address_list_entries, list_address_list_entries,
list_address_lists, list_address_lists,
@@ -60,6 +79,19 @@ class RecipientSnapshotItem:
provenance: dict[str, Any] = field(default_factory=dict) provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientSnapshotExcludedItem:
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str = "suppressed"
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RecipientSourceRef: class RecipientSourceRef:
source_id: str source_id: str
@@ -78,6 +110,9 @@ class RecipientSourceSnapshot:
source_revision: str source_revision: str
generated_at: str generated_at: str
recipients: tuple[RecipientSnapshotItem, ...] recipients: tuple[RecipientSnapshotItem, ...]
excluded: tuple[RecipientSnapshotExcludedItem, ...] = ()
purpose: str | None = None
requested_channels: tuple[str, ...] = ()
provenance: dict[str, Any] = field(default_factory=dict) provenance: dict[str, Any] = field(default_factory=dict)
@@ -283,13 +318,27 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal, principal: ApiPrincipal,
*, *,
address_book_id: str, address_book_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot: ) -> RecipientSourceSnapshot:
book = get_visible_address_book(session, principal, address_book_id) book = get_visible_address_book(session, principal, address_book_id)
contacts = _active_address_book_contacts(session, book.id) contacts = _active_address_book_contacts(session, book.id)
recipients: list[RecipientSnapshotItem] = [] recipients: list[RecipientSnapshotItem] = []
for contact in contacts: excluded: list[RecipientSnapshotExcludedItem] = []
for email in contact.emails: if purpose is None:
recipients.append(_recipient_snapshot_item(contact, email)) for contact in contacts:
for email in contact.emails:
recipients.append(_recipient_snapshot_item(contact, email))
else:
for contact in contacts:
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
contact,
purpose=purpose,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id)) revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
return RecipientSourceSnapshot( return RecipientSourceSnapshot(
source_id=f"addresses:address_book:{book.id}", source_id=f"addresses:address_book:{book.id}",
@@ -298,12 +347,18 @@ class AddressesRecipientSourceCapability:
source_revision=revision, source_revision=revision,
generated_at=utcnow().isoformat(), generated_at=utcnow().isoformat(),
recipients=tuple(recipients), recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={ provenance={
"module": "addresses", "module": "addresses",
"address_book_id": book.id, "address_book_id": book.id,
"scope_type": book.scope_type, "scope_type": book.scope_type,
"scope_id": book.scope_id, "scope_id": book.scope_id,
"tenant_id": book.tenant_id, "tenant_id": book.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
}, },
) )
@@ -313,15 +368,31 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal, principal: ApiPrincipal,
*, *,
address_list_id: str, address_list_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot: ) -> RecipientSourceSnapshot:
address_list = get_visible_address_list(session, principal, address_list_id) address_list = get_visible_address_list(session, principal, address_list_id)
entries = list_address_list_entries(session, principal, address_list.id) entries = list_address_list_entries(session, principal, address_list.id)
recipients: list[RecipientSnapshotItem] = [] recipients: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for entry in entries: for entry in entries:
email = _entry_email(entry) email = _entry_email(entry)
if email is None: if email is None:
continue continue
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry)) if purpose is None:
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
continue
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
entry.contact,
purpose=purpose,
email_ids={email.id},
address_list=address_list,
address_list_entry=entry,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id)) revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
return RecipientSourceSnapshot( return RecipientSourceSnapshot(
source_id=f"addresses:address_list:{address_list.id}", source_id=f"addresses:address_list:{address_list.id}",
@@ -330,6 +401,9 @@ class AddressesRecipientSourceCapability:
source_revision=revision, source_revision=revision,
generated_at=utcnow().isoformat(), generated_at=utcnow().isoformat(),
recipients=tuple(recipients), recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={ provenance={
"module": "addresses", "module": "addresses",
"address_book_id": address_list.address_book_id, "address_book_id": address_list.address_book_id,
@@ -337,6 +411,9 @@ class AddressesRecipientSourceCapability:
"scope_type": address_list.address_book.scope_type, "scope_type": address_list.address_book.scope_type,
"scope_id": address_list.address_book.scope_id, "scope_id": address_list.address_book.scope_id,
"tenant_id": address_list.tenant_id, "tenant_id": address_list.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
}, },
) )
@@ -346,16 +423,157 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal, principal: ApiPrincipal,
*, *,
source_id: str, source_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot: ) -> RecipientSourceSnapshot:
book_prefix = "addresses:address_book:" book_prefix = "addresses:address_book:"
if source_id.startswith(book_prefix): if source_id.startswith(book_prefix):
return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix)) return self.snapshot_address_book(
session,
principal,
address_book_id=source_id.removeprefix(book_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
list_prefix = "addresses:address_list:" list_prefix = "addresses:address_list:"
if source_id.startswith(list_prefix): if source_id.startswith(list_prefix):
return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix)) return self.snapshot_address_list(
session,
principal,
address_list_id=source_id.removeprefix(list_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
raise ValueError(f"Unsupported addresses recipient source id: {source_id}") raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
class AddressesChannelFactsCapability:
"""Resolve effective contact-point facts without making a Policy decision."""
def resolve_channel_facts(
self,
session: Any,
principal: ApiPrincipal,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts:
if request.tenant_id != principal.tenant_id:
raise PermissionError("Channel facts cannot be resolved across tenants.")
contact_id = _channel_facts_contact_id(request.source)
contact = get_visible_contact(session, principal, contact_id)
effective_at = _aware_datetime(request.effective_at)
active_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and _rule_is_effective(rule, effective_at)
]
expired_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and rule.effective_until is not None
and _aware_datetime(rule.effective_until) <= effective_at
]
revision, fingerprint = _contact_channel_revision(contact)
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
revision=revision,
fingerprint=fingerprint,
label=contact.display_name,
metadata={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
},
)
requested_channels = set(request.requested_channels)
candidates: list[DistributionChannelCandidate] = []
for channel, point_id, target, target_key, intrinsically_valid in _contact_channel_points(contact):
if requested_channels and channel not in requested_channels:
continue
matching = [
rule
for rule in active_rules
if rule.channel == channel
and (rule.contact_point_id is None or rule.contact_point_id == point_id)
]
selected = max(matching, key=_channel_rule_priority, default=None)
state = selected.decision if selected is not None else "unknown"
status = _channel_candidate_status(state, intrinsically_valid=intrinsically_valid)
reason_code = (
f"addresses.channel.{state}"
if intrinsically_valid
else f"addresses.{channel}.invalid"
)
explanation = (
_channel_rule_explanation(selected)
if selected is not None
else (
f"The {channel.replace('_', ' ')} contact point has no applicable governance fact."
if intrinsically_valid
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
)
)
candidates.append(
DistributionChannelCandidate(
channel=channel,
target=target,
target_key=target_key,
status=status,
contact_point_id=point_id,
locale=selected.locale if selected is not None else None,
preferred=any(rule.decision == "preferred" for rule in matching),
reason_code=reason_code,
explanation=explanation,
source=source,
decision_provenance={
"provider": "addresses",
"governance_state": state,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"selected_rule_id": selected.id if selected is not None else None,
"rule_ids": [rule.id for rule in matching],
"legal_basis": selected.legal_basis if selected is not None else None,
"evidence_ref": selected.evidence_ref if selected is not None else None,
"preference_rank": selected.preference_rank if selected is not None else None,
},
)
)
explanations = tuple(
DistributionExplanation(
code="addresses.channel_fact.expired",
message=(
f"A {rule.channel.replace('_', ' ')} governance fact expired and was not applied."
),
severity="info",
provider="addresses",
source=source,
provenance={"rule_id": rule.id, "decision": rule.decision},
)
for rule in expired_rules
)
return RecipientChannelFacts(
candidates=tuple(candidates),
explanations=explanations,
source_revision=revision,
source_fingerprint=fingerprint,
provenance={
"module": "addresses",
"contact_id": contact.id,
"address_book_id": contact.address_book_id,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"active_rule_ids": [rule.id for rule in active_rules],
"expired_rule_ids": [rule.id for rule in expired_rules],
"source_revision": revision,
"source_fingerprint": fingerprint,
},
)
def lookup_capability(_context: Any) -> AddressesLookupCapability: def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability() return AddressesLookupCapability()
@@ -368,6 +586,10 @@ def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapabi
return AddressesRecipientSourceCapability() return AddressesRecipientSourceCapability()
def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability:
return AddressesChannelFactsCapability()
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability: def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
return AddressesContactWriterCapability() return AddressesContactWriterCapability()
@@ -484,6 +706,7 @@ def _recipient_snapshot_item(
*, *,
address_list: AddressList | None = None, address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None, address_list_entry: AddressListEntry | None = None,
channel_decision: dict[str, Any] | None = None,
) -> RecipientSnapshotItem: ) -> RecipientSnapshotItem:
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None) primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
provenance = { provenance = {
@@ -501,6 +724,8 @@ def _recipient_snapshot_item(
"address_list_entry_kind": address_list_entry.target_kind, "address_list_entry_kind": address_list_entry.target_kind,
} }
) )
if channel_decision is not None:
provenance["channel_decision"] = dict(channel_decision)
return RecipientSnapshotItem( return RecipientSnapshotItem(
contact_id=contact.id, contact_id=contact.id,
display_name=contact.display_name, display_name=contact.display_name,
@@ -518,6 +743,91 @@ def _recipient_snapshot_item(
) )
def _governed_email_snapshot_rows(
session: Any,
principal: ApiPrincipal,
contact: Contact,
*,
purpose: str,
email_ids: set[str] | None = None,
address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None,
) -> tuple[list[RecipientSnapshotItem], list[RecipientSnapshotExcludedItem]]:
effective_at = utcnow()
facts = AddressesChannelFactsCapability().resolve_channel_facts(
session,
principal,
request=RecipientChannelFactsRequest(
tenant_id=principal.tenant_id,
source=DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
),
recipient_key=f"contact:{contact.id}",
effective_at=effective_at,
purpose=purpose,
requested_channels=("email",),
),
)
emails_by_id = {item.id: item for item in contact.emails}
included: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for candidate in facts.candidates:
if candidate.channel != "email":
continue
point_id = candidate.contact_point_id
if email_ids is not None and point_id not in email_ids:
continue
email = emails_by_id.get(point_id or "")
if email is None:
continue
decision = {
**dict(candidate.decision_provenance),
"status": candidate.status,
"reason_code": candidate.reason_code,
"explanation": candidate.explanation,
"source_revision": facts.source_revision,
"source_fingerprint": facts.source_fingerprint,
}
if candidate.status in {"usable", "stale"}:
included.append(
_recipient_snapshot_item(
contact,
email,
address_list=address_list,
address_list_entry=address_list_entry,
channel_decision=decision,
)
)
else:
provenance = {
**_contact_provenance(contact),
"channel_decision": decision,
}
if address_list is not None and address_list_entry is not None:
provenance.update(
{
"address_list_id": address_list.id,
"address_list_entry_id": address_list_entry.id,
}
)
excluded.append(
RecipientSnapshotExcludedItem(
contact_id=contact.id,
display_name=contact.display_name,
channel="email",
target=email.email,
contact_point_id=email.id,
status=candidate.status,
reason_code=candidate.reason_code,
explanation=candidate.explanation,
provenance=provenance,
)
)
return included, excluded
def _contact_provenance(contact: Contact) -> dict[str, Any]: def _contact_provenance(contact: Contact) -> dict[str, Any]:
return { return {
"module": "addresses", "module": "addresses",
@@ -638,7 +948,21 @@ 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 = (
session.query(Contact.address_book_id, func.max(ContactPostalAddress.updated_at))
.join(Contact, ContactPostalAddress.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()
)
rule_rows = (
session.query(Contact.address_book_id, func.max(ContactChannelRule.updated_at))
.join(Contact, ContactChannelRule.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]:
if updated_at is None: if updated_at is None:
continue continue
key = str(address_book_id) key = str(address_book_id)
@@ -670,7 +994,14 @@ 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()
) )
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows]: rule_rows = (
session.query(AddressListEntry.address_list_id, func.max(ContactChannelRule.updated_at))
.join(ContactChannelRule, AddressListEntry.contact_id == ContactChannelRule.contact_id)
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
.group_by(AddressListEntry.address_list_id)
.all()
)
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows, *rule_rows]:
if updated_at is None: if updated_at is None:
continue continue
key = str(address_list_id) key = str(address_list_id)
@@ -690,3 +1021,150 @@ def _address_list_source_revision(address_list: AddressList, latest_entry_update
if latest_entry_updated_at is not None: if latest_entry_updated_at is not None:
stamps.append(latest_entry_updated_at) stamps.append(latest_entry_updated_at)
return max(stamps).isoformat() return max(stamps).isoformat()
def _channel_facts_contact_id(source: DistributionSourceReference) -> str:
metadata_contact_id = source.metadata.get("contact_id")
if metadata_contact_id:
return str(metadata_contact_id)
if source.resource_type not in {"contact", "address_contact", "address_email"}:
raise ValueError(f"Unsupported Addresses channel-facts source: {source.resource_type}.")
return source.resource_id
def _contact_channel_points(
contact: Contact,
) -> tuple[tuple[str, str | None, str, str, bool], ...]:
points: list[tuple[str, str | None, str, str, bool]] = []
for email in contact.emails:
target = email.email.strip()
valid = bool(target and "@" in target and len(target) <= 320)
points.append(("email", email.id, target, f"email:{target.casefold()}", valid))
for address in contact.postal_addresses:
target = ", ".join(
item
for item in (
address.street,
" ".join(part for part in (address.postal_code, address.locality) if part),
address.region,
address.country,
)
if item
)
points.append(("postal", address.id, target, f"postal:{' '.join(target.casefold().split())}", bool(target)))
provenance = dict(contact.provenance or {})
internal_target = provenance.get("internal_mail_account_id") or provenance.get("account_id")
if internal_target:
target = str(internal_target).strip()
points.append(("internal_mail", None, target, f"internal_mail:{target.casefold()}", bool(target)))
portal_target = provenance.get("portal_target") or provenance.get("portal_account_id")
if portal_target:
target = str(portal_target).strip()
points.append(("portal", None, target, f"portal:{target.casefold()}", bool(target)))
return tuple(points)
def _rule_matches_purpose(rule: ContactChannelRule, purpose: str | None) -> bool:
return rule.purpose is None or (purpose is not None and rule.purpose == purpose)
def _rule_is_effective(rule: ContactChannelRule, effective_at: datetime) -> bool:
return (
(rule.effective_from is None or _aware_datetime(rule.effective_from) <= effective_at)
and (rule.effective_until is None or _aware_datetime(rule.effective_until) > effective_at)
)
def _channel_rule_priority(rule: ContactChannelRule) -> tuple[int, int, int, datetime]:
decision_priority = {
"invalid": 90,
"returned": 80,
"suppressed": 70,
"opted_out": 60,
"temporarily_unavailable": 50,
"preferred": 30,
"opted_in": 20,
"allowed": 10,
}.get(rule.decision, 0)
return (
decision_priority,
int(rule.contact_point_id is not None),
int(rule.purpose is not None),
_aware_datetime(rule.updated_at),
)
def _channel_candidate_status(state: str, *, intrinsically_valid: bool) -> str:
if not intrinsically_valid or state in {"invalid", "returned"}:
return "invalid"
if state in {"suppressed", "opted_out", "temporarily_unavailable"}:
return "suppressed"
return "usable"
def _channel_rule_explanation(rule: ContactChannelRule) -> str:
if rule.reason:
return rule.reason
return {
"allowed": "Use of this contact point is explicitly allowed.",
"opted_in": "The contact opted in to this communication channel.",
"preferred": "This is a preferred communication channel.",
"opted_out": "The contact opted out of this communication channel.",
"suppressed": "This contact point is suppressed.",
"invalid": "This contact point is marked invalid.",
"returned": "Delivery to this contact point was returned.",
"temporarily_unavailable": "This contact point is temporarily unavailable.",
}.get(rule.decision, "An Addresses governance fact was applied.")
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
stamps = [contact.updated_at]
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
revision = max(_aware_datetime(item) for item in stamps).isoformat()
payload = {
"contact_id": contact.id,
"revision": revision,
"emails": [
{"id": item.id, "email": item.email, "primary": item.is_primary}
for item in contact.emails
],
"postal": [
{
"id": item.id,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"primary": item.is_primary,
}
for item in contact.postal_addresses
],
"rules": [
{
"id": rule.id,
"channel": rule.channel,
"purpose": rule.purpose,
"point": rule.contact_point_id,
"decision": rule.decision,
"basis": rule.legal_basis,
"evidence": rule.evidence_ref,
"rank": rule.preference_rank,
"locale": rule.locale,
"from": rule.effective_from.isoformat() if rule.effective_from else None,
"until": rule.effective_until.isoformat() if rule.effective_until else None,
"updated": rule.updated_at.isoformat(),
}
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
],
}
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return revision, fingerprint
def _aware_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
@@ -92,6 +92,11 @@ class Contact(Base, TimestampMixin):
order_by="ContactPostalAddress.order_index", order_by="ContactPostalAddress.order_index",
) )
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan") address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
channel_rules: Mapped[list["ContactChannelRule"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactChannelRule.created_at",
)
class ContactEmail(Base, TimestampMixin): class ContactEmail(Base, TimestampMixin):
@@ -145,6 +150,47 @@ class ContactPostalAddress(Base, TimestampMixin):
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address") address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
class ContactChannelRule(Base, TimestampMixin):
__tablename__ = "addresses_contact_channel_rules"
__table_args__ = (
Index(
"ix_addresses_channel_rules_resolution",
"tenant_id",
"contact_id",
"channel",
"purpose",
),
Index(
"ix_addresses_channel_rules_effective",
"effective_from",
"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)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
decision: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
legal_basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
preference_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
locale: Mapped[str | None] = mapped_column(String(20), nullable=True)
effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, 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="channel_rules")
class AddressList(Base, TimestampMixin): class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists" __tablename__ = "addresses_address_lists"
__table_args__ = ( __table_args__ = (
+14 -2
View File
@@ -14,6 +14,7 @@ from govoplan_addresses.backend.db import models as addresses_models # noqa: F4
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -39,6 +40,7 @@ _addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactPostalAddress, addresses_models.ContactPostalAddress,
addresses_models.ContactPhone, addresses_models.ContactPhone,
addresses_models.ContactEmail, addresses_models.ContactEmail,
addresses_models.ContactChannelRule,
addresses_models.Contact, addresses_models.Contact,
addresses_models.AddressBook, addresses_models.AddressBook,
label="Addresses", label="Addresses",
@@ -95,6 +97,8 @@ PERMISSIONS = (
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."), _permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."), _permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."), _permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."), _permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."), _permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."), _permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
@@ -116,6 +120,8 @@ ROLE_TEMPLATES = (
"addresses:contact:read", "addresses:contact:read",
"addresses:contact:write", "addresses:contact:write",
"addresses:contact:delete", "addresses:contact:delete",
"addresses:governance:read",
"addresses:governance:write",
"addresses:sync:read", "addresses:sync:read",
"addresses:sync:write", "addresses:sync:write",
), ),
@@ -124,7 +130,7 @@ ROLE_TEMPLATES = (
slug="address_book_reader", slug="address_book_reader",
name="Address book reader", name="Address book reader",
description="Read visible address books and contacts.", description="Read visible address books and contacts.",
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"), permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
), ),
) )
@@ -155,8 +161,9 @@ manifest = ModuleManifest(
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_addresses_router, route_factory=_addresses_router,
@@ -188,6 +195,10 @@ manifest = ModuleManifest(
"govoplan_addresses.backend.capabilities", "govoplan_addresses.backend.capabilities",
fromlist=["contact_writer_capability"], fromlist=["contact_writer_capability"],
).contact_writer_capability(context), ).contact_writer_capability(context),
CAPABILITY_RECIPIENT_CHANNEL_FACTS: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["channel_facts_capability"],
).channel_facts_capability(context),
}, },
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
@@ -202,6 +213,7 @@ manifest = ModuleManifest(
addresses_models.ContactEmail, addresses_models.ContactEmail,
addresses_models.ContactPhone, addresses_models.ContactPhone,
addresses_models.ContactPostalAddress, addresses_models.ContactPostalAddress,
addresses_models.ContactChannelRule,
label="Addresses", label="Addresses",
), ),
), ),
@@ -0,0 +1,58 @@
"""Add effective-dated contact channel governance.
Revision ID: f2a4b5c6d7e
Revises: e1f2a4b5c6d
"""
from alembic import op
import sqlalchemy as sa
revision = "f2a4b5c6d7e"
down_revision = "e1f2a4b5c6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"addresses_contact_channel_rules",
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("purpose", sa.String(length=120), nullable=True),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("decision", sa.String(length=40), nullable=False),
sa.Column("legal_basis", sa.String(length=255), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("preference_rank", sa.Integer(), nullable=True),
sa.Column("locale", sa.String(length=20), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
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),
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_channel_rules_tenant_id", ["tenant_id"]),
("ix_addresses_contact_channel_rules_contact_id", ["contact_id"]),
("ix_addresses_contact_channel_rules_channel", ["channel"]),
("ix_addresses_contact_channel_rules_purpose", ["purpose"]),
("ix_addresses_contact_channel_rules_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_channel_rules_decision", ["decision"]),
("ix_addresses_contact_channel_rules_effective_from", ["effective_from"]),
("ix_addresses_contact_channel_rules_effective_until", ["effective_until"]),
("ix_addresses_contact_channel_rules_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_channel_rules_resolution", ["tenant_id", "contact_id", "channel", "purpose"]),
("ix_addresses_channel_rules_effective", ["effective_from", "effective_until"]),
):
op.create_index(name, "addresses_contact_channel_rules", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_channel_rules")
+102
View File
@@ -19,6 +19,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource, AddressSyncSource,
AddressSyncTombstone, AddressSyncTombstone,
Contact, Contact,
ContactChannelRule,
ContactPostalAddress, ContactPostalAddress,
) )
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
@@ -62,6 +63,9 @@ from govoplan_addresses.backend.schemas import (
AddressSyncTombstoneListResponse, AddressSyncTombstoneListResponse,
AddressSyncTombstoneResponse, AddressSyncTombstoneResponse,
ContactCreateRequest, ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse,
ContactChannelRuleResponse,
ContactListResponse, ContactListResponse,
ContactResponse, ContactResponse,
ContactUpdateRequest, ContactUpdateRequest,
@@ -79,6 +83,7 @@ from govoplan_addresses.backend.service import (
create_address_list_entry, create_address_list_entry,
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_contact_channel_rule,
create_sync_source, create_sync_source,
count_contacts, count_contacts,
delete_address_book, delete_address_book,
@@ -89,12 +94,14 @@ from govoplan_addresses.backend.service import (
discover_carddav_address_books, discover_carddav_address_books,
export_address_book_vcard, export_address_book_vcard,
export_contact_vcard, export_contact_vcard,
end_contact_channel_rule,
finish_sync_attempt, finish_sync_attempt,
import_vcards, import_vcards,
list_address_list_entries, list_address_list_entries,
list_address_lists, list_address_lists,
list_address_books, list_address_books,
list_contacts, list_contacts,
list_contact_channel_rules,
list_sync_conflicts, list_sync_conflicts,
list_sync_diagnostics, list_sync_diagnostics,
list_sync_sources, list_sync_sources,
@@ -156,6 +163,10 @@ def _contact_response(contact: Contact) -> ContactResponse:
return ContactResponse.model_validate(contact) return ContactResponse.model_validate(contact)
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
return ContactChannelRuleResponse.model_validate(rule)
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(
{ {
@@ -1081,6 +1092,97 @@ def api_update_contact(
raise _error(exc) from exc raise _error(exc) from exc
@router.get("/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleListResponse)
def api_list_contact_channel_rules(
contact_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:read")
try:
return ContactChannelRuleListResponse(
rules=[
_channel_rule_response(rule)
for rule in list_contact_channel_rules(session, principal, contact_id)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contacts/{contact_id}/channel-rules",
response_model=ContactChannelRuleResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_contact_channel_rule(
contact_id: str,
payload: ContactChannelRuleCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
rule = create_contact_channel_rule(session, principal, contact_id, payload)
_audit_address_sync(
session,
principal,
action="addresses.channel_rule_created",
object_type="address_contact_channel_rule",
object_id=rule.id,
details={
"contact_id": contact_id,
"channel": rule.channel,
"purpose": rule.purpose,
"contact_point_id": rule.contact_point_id,
"decision": rule.decision,
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
"evidence_ref": rule.evidence_ref,
},
)
session.commit()
session.refresh(rule)
return _channel_rule_response(rule)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.delete(
"/contact-channel-rules/{rule_id}",
response_model=ContactChannelRuleResponse,
)
def api_end_contact_channel_rule(
rule_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
rule = end_contact_channel_rule(session, principal, rule_id)
_audit_address_sync(
session,
principal,
action="addresses.channel_rule_ended",
object_type="address_contact_channel_rule",
object_id=rule.id,
details={
"contact_id": rule.contact_id,
"channel": rule.channel,
"purpose": rule.purpose,
"decision": rule.decision,
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
},
)
session.commit()
session.refresh(rule)
return _channel_rule_response(rule)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_contact( def api_delete_contact(
contact_id: str, contact_id: str,
+55
View File
@@ -14,6 +14,17 @@ AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"] AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
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"]
AddressChannelDecision = Literal[
"allowed",
"opted_in",
"preferred",
"opted_out",
"suppressed",
"invalid",
"returned",
"temporarily_unavailable",
]
class ContactEmailPayload(BaseModel): class ContactEmailPayload(BaseModel):
@@ -227,6 +238,50 @@ class ContactListResponse(BaseModel):
has_more: bool has_more: bool
class ContactChannelRuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressDistributionChannel
purpose: str | None = Field(default=None, max_length=120)
contact_point_id: str | None = Field(default=None, max_length=36)
decision: AddressChannelDecision
legal_basis: str | None = Field(default=None, max_length=255)
evidence_ref: str | None = Field(default=None, max_length=1000)
reason: str | None = None
preference_rank: int | None = Field(default=None, ge=0, le=10000)
locale: str | None = Field(default=None, max_length=20)
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactChannelRuleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressDistributionChannel
purpose: str | None = None
contact_point_id: str | None = None
decision: AddressChannelDecision
legal_basis: str | None = None
evidence_ref: str | None = None
reason: str | None = None
preference_rank: int | None = None
locale: str | None = None
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_by_account_id: str | None = None
created_at: datetime
updated_at: datetime
class ContactChannelRuleListResponse(BaseModel):
rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
class AddressLookupResponse(BaseModel): class AddressLookupResponse(BaseModel):
contacts: list[ContactResponse] contacts: list[ContactResponse]
+76
View File
@@ -43,6 +43,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource, AddressSyncSource,
AddressSyncTombstone, AddressSyncTombstone,
Contact, Contact,
ContactChannelRule,
ContactEmail, ContactEmail,
ContactPhone, ContactPhone,
ContactPostalAddress, ContactPostalAddress,
@@ -68,6 +69,7 @@ from govoplan_addresses.backend.schemas import (
ContactPhonePayload, ContactPhonePayload,
ContactPostalAddressPayload, ContactPostalAddressPayload,
ContactUpdateRequest, ContactUpdateRequest,
ContactChannelRuleCreateRequest,
) )
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
@@ -2514,6 +2516,80 @@ def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: s
return contact return contact
def list_contact_channel_rules(
session: Session,
principal: ApiPrincipal,
contact_id: str,
) -> list[ContactChannelRule]:
contact = get_visible_contact(session, principal, contact_id)
return (
session.query(ContactChannelRule)
.filter(ContactChannelRule.contact_id == contact.id)
.order_by(ContactChannelRule.created_at.desc(), ContactChannelRule.id.asc())
.all()
)
def create_contact_channel_rule(
session: Session,
principal: ApiPrincipal,
contact_id: str,
payload: ContactChannelRuleCreateRequest,
) -> ContactChannelRule:
contact = get_visible_contact(session, principal, contact_id)
if (
payload.effective_from is not None
and payload.effective_until is not None
and payload.effective_until <= payload.effective_from
):
raise AddressBookError("Channel-rule end must be after its start.")
point_ids: set[str] = set()
if payload.channel == "email":
point_ids = {item.id for item in contact.emails}
elif payload.channel == "postal":
point_ids = {item.id for item in contact.postal_addresses}
if payload.contact_point_id and payload.contact_point_id not in point_ids:
raise AddressBookError(
"The selected contact point does not belong to this contact and channel."
)
rule = ContactChannelRule(
tenant_id=contact.tenant_id,
contact_id=contact.id,
channel=payload.channel,
purpose=_trim(payload.purpose),
contact_point_id=payload.contact_point_id,
decision=payload.decision,
legal_basis=_trim(payload.legal_basis),
evidence_ref=_trim(payload.evidence_ref),
reason=_trim(payload.reason),
preference_rank=payload.preference_rank,
locale=_trim(payload.locale),
effective_from=payload.effective_from,
effective_until=payload.effective_until,
created_by_account_id=_account_id(principal),
metadata_=dict(payload.metadata),
)
session.add(rule)
session.flush()
return rule
def end_contact_channel_rule(
session: Session,
principal: ApiPrincipal,
rule_id: str,
) -> ContactChannelRule:
rule = session.get(ContactChannelRule, rule_id)
if rule is None:
raise AddressBookError("Contact channel rule not found.")
get_visible_contact(session, principal, rule.contact_id)
now = utcnow()
if rule.effective_until is None or rule.effective_until > now:
rule.effective_until = now
session.add(rule)
return rule
def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact: def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact:
contact = get_visible_contact(session, principal, contact_id) contact = get_visible_contact(session, principal, contact_id)
_require_mutable_book(contact.address_book) _require_mutable_book(contact.address_book)
+130
View File
@@ -1,12 +1,18 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.distribution_lists import (
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
DistributionSourceReference,
RecipientChannelFactsRequest,
)
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
@@ -19,6 +25,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_CONTACT_WRITER,
CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_LOOKUP,
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesChannelFactsCapability,
AddressesContactWriterCapability, AddressesContactWriterCapability,
AddressesLookupCapability, AddressesLookupCapability,
AddressesPeopleSearchProvider, AddressesPeopleSearchProvider,
@@ -34,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource, AddressSyncSource,
AddressSyncTombstone, AddressSyncTombstone,
Contact, Contact,
ContactChannelRule,
ContactEmail, ContactEmail,
ContactPhone, ContactPhone,
ContactPostalAddress, ContactPostalAddress,
@@ -52,6 +60,7 @@ from govoplan_addresses.backend.schemas import (
AddressSyncSourceUpdateRequest, AddressSyncSourceUpdateRequest,
AddressSyncTombstoneCreateRequest, AddressSyncTombstoneCreateRequest,
ContactCreateRequest, ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactEmailPayload, ContactEmailPayload,
ContactPostalAddressPayload, ContactPostalAddressPayload,
) )
@@ -66,10 +75,12 @@ from govoplan_addresses.backend.service import (
create_address_list_entry, create_address_list_entry,
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_contact_channel_rule,
create_sync_source, create_sync_source,
count_contacts, count_contacts,
delete_address_list_entry, delete_address_list_entry,
delete_contact, delete_contact,
end_contact_channel_rule,
delete_sync_source, delete_sync_source,
discover_carddav_address_books, discover_carddav_address_books,
export_address_book_vcard, export_address_book_vcard,
@@ -78,6 +89,7 @@ from govoplan_addresses.backend.service import (
list_address_lists, list_address_lists,
list_address_books, list_address_books,
list_contacts, list_contacts,
list_contact_channel_rules,
list_sync_conflicts, list_sync_conflicts,
list_sync_diagnostics, list_sync_diagnostics,
list_sync_sources, list_sync_sources,
@@ -147,6 +159,8 @@ class Principal:
"addresses:contact:read", "addresses:contact:read",
"addresses:contact:write", "addresses:contact:write",
"addresses:contact:delete", "addresses:contact:delete",
"addresses:governance:read",
"addresses:governance:write",
"addresses:sync:read", "addresses:sync:read",
"addresses:sync:write", "addresses:sync:write",
} }
@@ -181,6 +195,7 @@ class AddressServiceTest(unittest.TestCase):
ContactEmail.__table__, ContactEmail.__table__,
ContactPhone.__table__, ContactPhone.__table__,
ContactPostalAddress.__table__, ContactPostalAddress.__table__,
ContactChannelRule.__table__,
AddressListEntry.__table__, AddressListEntry.__table__,
AddressSyncSource.__table__, AddressSyncSource.__table__,
AddressSyncTombstone.__table__, AddressSyncTombstone.__table__,
@@ -381,6 +396,7 @@ END:VCARD
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided)
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
self.session.commit() self.session.commit()
@@ -459,6 +475,120 @@ END:VCARD
) )
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only") self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
def test_channel_facts_are_effective_purpose_aware_and_provenance_bearing(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Governed recipients"),
)
self.session.flush()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email="ada@example.local", is_primary=True)],
postal_addresses=[
ContactPostalAddressPayload(
street="Main Street 1",
postal_code="10115",
locality="Berlin",
country="Germany",
is_primary=True,
)
],
),
)
self.session.flush()
rule = create_contact_channel_rule(
self.session,
self.principal,
contact.id,
ContactChannelRuleCreateRequest(
channel="email",
purpose="campaign_delivery",
contact_point_id=contact.emails[0].id,
decision="opted_out",
legal_basis="consent",
evidence_ref="case:consent-42",
reason="Recipient withdrew email consent.",
),
)
self.session.commit()
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
)
facts = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email", "postal"),
),
)
candidates = {item.channel: item for item in facts.candidates}
self.assertEqual(candidates["email"].status, "suppressed")
self.assertEqual(candidates["email"].reason_code, "addresses.channel.opted_out")
self.assertEqual(candidates["email"].decision_provenance["selected_rule_id"], rule.id)
self.assertEqual(candidates["email"].decision_provenance["evidence_ref"], "case:consent-42")
self.assertEqual(candidates["postal"].status, "usable")
self.assertEqual(candidates["postal"].decision_provenance["governance_state"], "unknown")
self.assertTrue(facts.source_revision)
self.assertEqual(len(facts.source_fingerprint or ""), 64)
governed_snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
self.session,
self.principal,
address_book_id=book.id,
purpose="campaign_delivery",
)
self.assertEqual(governed_snapshot.recipients, ())
self.assertEqual(len(governed_snapshot.excluded), 1)
self.assertEqual(governed_snapshot.excluded[0].reason_code, "addresses.channel.opted_out")
self.assertTrue(governed_snapshot.provenance["governance_applied"])
unrelated = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="service_notice",
requested_channels=("email",),
),
)
self.assertEqual(unrelated.candidates[0].status, "usable")
self.assertEqual(unrelated.candidates[0].decision_provenance["governance_state"], "unknown")
ended = end_contact_channel_rule(self.session, self.principal, rule.id)
self.session.commit()
expired = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email",),
),
)
self.assertIsNotNone(ended.effective_until)
self.assertEqual(expired.candidates[0].status, "usable")
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id)
def test_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"))
+76
View File
@@ -84,6 +84,53 @@ export type Contact = {
updated_at: string; 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 = { export type AddressBookCreatePayload = {
scope_type: AddressBookScope; scope_type: AddressBookScope;
group_id?: string | null; group_id?: string | null;
@@ -341,6 +388,10 @@ type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[]; conflicts: AddressSyncConflict[];
}; };
type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
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)) {
@@ -626,6 +677,31 @@ 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 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> { export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, { return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
method: "POST", 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 { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { import {
ApiError, ApiError,
@@ -30,12 +30,14 @@ import {
createAddressListEntry, createAddressListEntry,
createCardDavSyncSource, createCardDavSyncSource,
createContact, createContact,
createContactChannelRule,
deleteAddressBook, deleteAddressBook,
deleteAddressList, deleteAddressList,
deleteAddressListEntry, deleteAddressListEntry,
deleteContact, deleteContact,
deleteAddressSyncSource, deleteAddressSyncSource,
discoverCardDavAddressBooks, discoverCardDavAddressBooks,
endContactChannelRule,
exportAddressBookVcards, exportAddressBookVcards,
exportContactVcard, exportContactVcard,
importAddressBookVcards, importAddressBookVcards,
@@ -49,6 +51,7 @@ import {
listAddressSyncTombstones, listAddressSyncTombstones,
listContacts, listContacts,
listContactsPage, listContactsPage,
listContactChannelRules,
previewAddressSyncSource, previewAddressSyncSource,
restoreAddressBook, restoreAddressBook,
restoreAddressList, restoreAddressList,
@@ -62,7 +65,9 @@ import {
type AddressCardDavAddressBook, type AddressCardDavAddressBook,
type AddressBook, type AddressBook,
type AddressBookScope, type AddressBookScope,
type AddressChannelDecision,
type AddressCredentialEnvelope, type AddressCredentialEnvelope,
type AddressDistributionChannel,
type AddressList, type AddressList,
type AddressListEntry, type AddressListEntry,
type AddressSyncConflict, type AddressSyncConflict,
@@ -70,7 +75,9 @@ import {
type AddressSyncPlan, type AddressSyncPlan,
type AddressSyncSource, type AddressSyncSource,
type AddressSyncTombstone, type AddressSyncTombstone,
type Contact type Contact,
type ContactChannelRule,
type ContactChannelRulePayload
} from "../../api/addresses"; } from "../../api/addresses";
type Props = { type Props = {
@@ -139,6 +146,20 @@ type ContactFormState = {
note: string; 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 = { type ListFormState = {
name: string; name: string;
description: string; description: string;
@@ -212,6 +233,20 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
sync_direction: "read_only" 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 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;
@@ -613,6 +648,13 @@ function downloadText(filename: string, content: string, type = "text/vcard;char
window.URL.revokeObjectURL(url); 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 { function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? ""; 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 [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null); const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM); 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 [memberDialogOpen, setMemberDialogOpen] = useState(false);
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]); const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
const [memberQuery, setMemberQuery] = useState(""); 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 canDeleteLists = hasScope(auth, "addresses:address_list:delete");
const canWriteContacts = hasScope(auth, "addresses:contact:write"); const canWriteContacts = hasScope(auth, "addresses:contact:write");
const canDeleteContacts = hasScope(auth, "addresses:contact:delete"); 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 canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write"); const canWriteSync = hasScope(auth, "addresses:sync:write");
@@ -744,6 +791,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}); });
}, [memberCandidates, memberQuery, selectedListContactIds]); }, [memberCandidates, memberQuery, selectedListContactIds]);
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null; 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 selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : ""; const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
const loadingReason = loading ? "Address books are loading." : ""; const loadingReason = loading ? "Address books are loading." : "";
@@ -827,6 +888,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason], [saving, savingReason],
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."] [!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 dialogCancelReason = disabledReason([saving, savingReason]);
const vcardImportReason = disabledReason( const vcardImportReason = disabledReason(
[saving, savingReason], [saving, savingReason],
@@ -1121,6 +1195,71 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setContactDialog({ mode: "edit", contact }); 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">>) { function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
setContactForm((current) => ({ setContactForm((current) => ({
...current, ...current,
@@ -1901,6 +2040,17 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedContact.deleted_at ? {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="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="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>
@@ -2235,6 +2385,128 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</form> </form>
</Dialog> </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 <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"}
+45
View File
@@ -539,6 +539,51 @@
gap: 8px; 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-email,
.address-form-row-phone { .address-form-row-phone {
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px; grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;