From 41ccd4c8070bc497cb8c1dee934e5bc3739d8c49 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 22:48:07 +0200 Subject: [PATCH] Add governed contact channel facts --- .../backend/capabilities.py | 498 +++++++++++++++++- src/govoplan_addresses/backend/db/models.py | 46 ++ src/govoplan_addresses/backend/manifest.py | 16 +- ...2a4b5c6d7e_addresses_channel_governance.py | 58 ++ src/govoplan_addresses/backend/router.py | 102 ++++ src/govoplan_addresses/backend/schemas.py | 55 ++ src/govoplan_addresses/backend/service.py | 76 +++ tests/test_addresses_service.py | 130 +++++ webui/src/api/addresses.ts | 76 +++ .../features/addressbook/AddressBookPage.tsx | 276 +++++++++- webui/src/styles/addresses.css | 45 ++ 11 files changed, 1364 insertions(+), 14 deletions(-) create mode 100644 src/govoplan_addresses/backend/migrations/versions/f2a4b5c6d7e_addresses_channel_governance.py diff --git a/src/govoplan_addresses/backend/capabilities.py b/src/govoplan_addresses/backend/capabilities.py index c6f552f..6f2d4bb 100644 --- a/src/govoplan_addresses/backend/capabilities.py +++ b/src/govoplan_addresses/backend/capabilities.py @@ -1,25 +1,44 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime +import hashlib +import json from typing import Any from sqlalchemy import func 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.core.people import ( PeopleSearchGroup, PersonSearchCandidate, 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.service import ( AddressBookError, create_contact, get_visible_address_book, get_visible_address_list, + get_visible_contact, list_address_books, list_address_list_entries, list_address_lists, @@ -60,6 +79,19 @@ class RecipientSnapshotItem: 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) class RecipientSourceRef: source_id: str @@ -78,6 +110,9 @@ class RecipientSourceSnapshot: source_revision: str generated_at: str recipients: tuple[RecipientSnapshotItem, ...] + excluded: tuple[RecipientSnapshotExcludedItem, ...] = () + purpose: str | None = None + requested_channels: tuple[str, ...] = () provenance: dict[str, Any] = field(default_factory=dict) @@ -283,13 +318,27 @@ class AddressesRecipientSourceCapability: principal: ApiPrincipal, *, address_book_id: str, + purpose: str | None = None, + requested_channels: tuple[str, ...] = ("email",), ) -> RecipientSourceSnapshot: book = get_visible_address_book(session, principal, address_book_id) contacts = _active_address_book_contacts(session, book.id) recipients: list[RecipientSnapshotItem] = [] - for contact in contacts: - for email in contact.emails: - recipients.append(_recipient_snapshot_item(contact, email)) + excluded: list[RecipientSnapshotExcludedItem] = [] + if purpose is None: + 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)) return RecipientSourceSnapshot( source_id=f"addresses:address_book:{book.id}", @@ -298,12 +347,18 @@ class AddressesRecipientSourceCapability: source_revision=revision, generated_at=utcnow().isoformat(), recipients=tuple(recipients), + excluded=tuple(excluded), + purpose=purpose, + requested_channels=requested_channels, provenance={ "module": "addresses", "address_book_id": book.id, "scope_type": book.scope_type, "scope_id": book.scope_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, *, address_list_id: str, + purpose: str | None = None, + requested_channels: tuple[str, ...] = ("email",), ) -> RecipientSourceSnapshot: address_list = get_visible_address_list(session, principal, address_list_id) entries = list_address_list_entries(session, principal, address_list.id) recipients: list[RecipientSnapshotItem] = [] + excluded: list[RecipientSnapshotExcludedItem] = [] for entry in entries: email = _entry_email(entry) if email is None: 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)) return RecipientSourceSnapshot( source_id=f"addresses:address_list:{address_list.id}", @@ -330,6 +401,9 @@ class AddressesRecipientSourceCapability: source_revision=revision, generated_at=utcnow().isoformat(), recipients=tuple(recipients), + excluded=tuple(excluded), + purpose=purpose, + requested_channels=requested_channels, provenance={ "module": "addresses", "address_book_id": address_list.address_book_id, @@ -337,6 +411,9 @@ class AddressesRecipientSourceCapability: "scope_type": address_list.address_book.scope_type, "scope_id": address_list.address_book.scope_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, *, source_id: str, + purpose: str | None = None, + requested_channels: tuple[str, ...] = ("email",), ) -> RecipientSourceSnapshot: book_prefix = "addresses:address_book:" 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:" 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}") +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: return AddressesLookupCapability() @@ -368,6 +586,10 @@ def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapabi return AddressesRecipientSourceCapability() +def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability: + return AddressesChannelFactsCapability() + + def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability: return AddressesContactWriterCapability() @@ -484,6 +706,7 @@ def _recipient_snapshot_item( *, address_list: AddressList | None = None, address_list_entry: AddressListEntry | None = None, + channel_decision: dict[str, Any] | None = None, ) -> RecipientSnapshotItem: primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None) provenance = { @@ -501,6 +724,8 @@ def _recipient_snapshot_item( "address_list_entry_kind": address_list_entry.target_kind, } ) + if channel_decision is not None: + provenance["channel_decision"] = dict(channel_decision) return RecipientSnapshotItem( contact_id=contact.id, 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]: return { "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) .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: continue 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) .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: continue 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: stamps.append(latest_entry_updated_at) 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 diff --git a/src/govoplan_addresses/backend/db/models.py b/src/govoplan_addresses/backend/db/models.py index 6bde025..b351124 100644 --- a/src/govoplan_addresses/backend/db/models.py +++ b/src/govoplan_addresses/backend/db/models.py @@ -92,6 +92,11 @@ class Contact(Base, TimestampMixin): order_by="ContactPostalAddress.order_index", ) 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): @@ -145,6 +150,47 @@ class ContactPostalAddress(Base, TimestampMixin): 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): __tablename__ = "addresses_address_lists" __table_args__ = ( diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py index 3ce8abe..4702a0f 100644 --- a/src/govoplan_addresses/backend/manifest.py +++ b/src/govoplan_addresses/backend/manifest.py @@ -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.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.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS from govoplan_core.core.modules import ( DocumentationTopic, FrontendModule, @@ -39,6 +40,7 @@ _addresses_table_retirement_provider = drop_table_retirement_provider( addresses_models.ContactPostalAddress, addresses_models.ContactPhone, addresses_models.ContactEmail, + addresses_models.ContactChannelRule, addresses_models.Contact, addresses_models.AddressBook, label="Addresses", @@ -95,6 +97,8 @@ PERMISSIONS = ( _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: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: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."), @@ -116,6 +120,8 @@ ROLE_TEMPLATES = ( "addresses:contact:read", "addresses:contact:write", "addresses:contact:delete", + "addresses:governance:read", + "addresses:governance:write", "addresses:sync:read", "addresses:sync:write", ), @@ -124,7 +130,7 @@ ROLE_TEMPLATES = ( slug="address_book_reader", name="Address book reader", 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=( ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), 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_RECIPIENT_CHANNEL_FACTS, version="0.1.0"), ), permissions=PERMISSIONS, route_factory=_addresses_router, @@ -188,6 +195,10 @@ manifest = ModuleManifest( "govoplan_addresses.backend.capabilities", fromlist=["contact_writer_capability"], ).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=( persistent_table_uninstall_guard( @@ -202,6 +213,7 @@ manifest = ModuleManifest( addresses_models.ContactEmail, addresses_models.ContactPhone, addresses_models.ContactPostalAddress, + addresses_models.ContactChannelRule, label="Addresses", ), ), diff --git a/src/govoplan_addresses/backend/migrations/versions/f2a4b5c6d7e_addresses_channel_governance.py b/src/govoplan_addresses/backend/migrations/versions/f2a4b5c6d7e_addresses_channel_governance.py new file mode 100644 index 0000000..6a011a7 --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/f2a4b5c6d7e_addresses_channel_governance.py @@ -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") diff --git a/src/govoplan_addresses/backend/router.py b/src/govoplan_addresses/backend/router.py index 43e53cb..6dcc880 100644 --- a/src/govoplan_addresses/backend/router.py +++ b/src/govoplan_addresses/backend/router.py @@ -19,6 +19,7 @@ from govoplan_addresses.backend.db.models import ( AddressSyncSource, AddressSyncTombstone, Contact, + ContactChannelRule, ContactPostalAddress, ) from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability @@ -62,6 +63,9 @@ from govoplan_addresses.backend.schemas import ( AddressSyncTombstoneListResponse, AddressSyncTombstoneResponse, ContactCreateRequest, + ContactChannelRuleCreateRequest, + ContactChannelRuleListResponse, + ContactChannelRuleResponse, ContactListResponse, ContactResponse, ContactUpdateRequest, @@ -79,6 +83,7 @@ from govoplan_addresses.backend.service import ( create_address_list_entry, create_carddav_sync_source, create_contact, + create_contact_channel_rule, create_sync_source, count_contacts, delete_address_book, @@ -89,12 +94,14 @@ from govoplan_addresses.backend.service import ( discover_carddav_address_books, export_address_book_vcard, export_contact_vcard, + end_contact_channel_rule, finish_sync_attempt, import_vcards, list_address_list_entries, list_address_lists, list_address_books, list_contacts, + list_contact_channel_rules, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, @@ -156,6 +163,10 @@ def _contact_response(contact: Contact) -> ContactResponse: 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: return AddressListResponse.model_validate( { @@ -1081,6 +1092,97 @@ def api_update_contact( 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) def api_delete_contact( contact_id: str, diff --git a/src/govoplan_addresses/backend/schemas.py b/src/govoplan_addresses/backend/schemas.py index 6e18f00..b6ab2ff 100644 --- a/src/govoplan_addresses/backend/schemas.py +++ b/src/govoplan_addresses/backend/schemas.py @@ -14,6 +14,17 @@ AddressSyncConflictStatus = Literal["open", "resolved", "ignored"] AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"] AddressCardDavAuthType = Literal["none", "basic", "bearer"] AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"] +AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"] +AddressChannelDecision = Literal[ + "allowed", + "opted_in", + "preferred", + "opted_out", + "suppressed", + "invalid", + "returned", + "temporarily_unavailable", +] class ContactEmailPayload(BaseModel): @@ -227,6 +238,50 @@ class ContactListResponse(BaseModel): 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): contacts: list[ContactResponse] diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py index 3fd24dd..7d942d8 100644 --- a/src/govoplan_addresses/backend/service.py +++ b/src/govoplan_addresses/backend/service.py @@ -43,6 +43,7 @@ from govoplan_addresses.backend.db.models import ( AddressSyncSource, AddressSyncTombstone, Contact, + ContactChannelRule, ContactEmail, ContactPhone, ContactPostalAddress, @@ -68,6 +69,7 @@ from govoplan_addresses.backend.schemas import ( ContactPhonePayload, ContactPostalAddressPayload, ContactUpdateRequest, + ContactChannelRuleCreateRequest, ) 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 +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: contact = get_visible_contact(session, principal, contact_id) _require_mutable_book(contact.address_book) diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py index afb2748..897542f 100644 --- a/tests/test_addresses_service.py +++ b/tests/test_addresses_service.py @@ -1,12 +1,18 @@ from __future__ import annotations import unittest +from datetime import timedelta from unittest.mock import patch from sqlalchemy import create_engine, inspect from sqlalchemy.orm import sessionmaker 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.db.base import Base from govoplan_core.db.base import utcnow @@ -19,6 +25,7 @@ from govoplan_addresses.backend.capabilities import ( CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, + AddressesChannelFactsCapability, AddressesContactWriterCapability, AddressesLookupCapability, AddressesPeopleSearchProvider, @@ -34,6 +41,7 @@ from govoplan_addresses.backend.db.models import ( AddressSyncSource, AddressSyncTombstone, Contact, + ContactChannelRule, ContactEmail, ContactPhone, ContactPostalAddress, @@ -52,6 +60,7 @@ from govoplan_addresses.backend.schemas import ( AddressSyncSourceUpdateRequest, AddressSyncTombstoneCreateRequest, ContactCreateRequest, + ContactChannelRuleCreateRequest, ContactEmailPayload, ContactPostalAddressPayload, ) @@ -66,10 +75,12 @@ from govoplan_addresses.backend.service import ( create_address_list_entry, create_carddav_sync_source, create_contact, + create_contact_channel_rule, create_sync_source, count_contacts, delete_address_list_entry, delete_contact, + end_contact_channel_rule, delete_sync_source, discover_carddav_address_books, export_address_book_vcard, @@ -78,6 +89,7 @@ from govoplan_addresses.backend.service import ( list_address_lists, list_address_books, list_contacts, + list_contact_channel_rules, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, @@ -147,6 +159,8 @@ class Principal: "addresses:contact:read", "addresses:contact:write", "addresses:contact:delete", + "addresses:governance:read", + "addresses:governance:write", "addresses:sync:read", "addresses:sync:write", } @@ -181,6 +195,7 @@ class AddressServiceTest(unittest.TestCase): ContactEmail.__table__, ContactPhone.__table__, ContactPostalAddress.__table__, + ContactChannelRule.__table__, AddressListEntry.__table__, AddressSyncSource.__table__, AddressSyncTombstone.__table__, @@ -381,6 +396,7 @@ END:VCARD self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, 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")) self.session.commit() @@ -459,6 +475,120 @@ END:VCARD ) 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: 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")) diff --git a/webui/src/api/addresses.ts b/webui/src/api/addresses.ts index 40ccfc4..c318aba 100644 --- a/webui/src/api/addresses.ts +++ b/webui/src/api/addresses.ts @@ -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; + 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; +}; + 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 { 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(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" }); } +export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/contacts/${contactId}/channel-rules` + ); + return response.rules; +} + +export function createContactChannelRule( + settings: ApiSettings, + contactId: string, + payload: ContactChannelRulePayload +): Promise { + return apiFetch(settings, `/api/v1/addresses/contacts/${contactId}/channel-rules`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function endContactChannelRule(settings: ApiSettings, ruleId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/contact-channel-rules/${ruleId}`, { + method: "DELETE" + }); +} + export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise { return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, { method: "POST", diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index ee7ec25..956e698 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -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(EMPTY_LIST_FORM); const [contactDialog, setContactDialog] = useState(null); const [contactForm, setContactForm] = useState(EMPTY_CONTACT_FORM); + const [governanceContact, setGovernanceContact] = useState(null); + const [channelRules, setChannelRules] = useState([]); + const [channelRuleForm, setChannelRuleForm] = useState(EMPTY_CHANNEL_RULE_FORM); const [memberDialogOpen, setMemberDialogOpen] = useState(false); const [memberCandidates, setMemberCandidates] = 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 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) { + 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>) { setContactForm((current) => ({ ...current, @@ -1901,6 +2040,17 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {selectedContact.deleted_at ? : <> + @@ -2235,6 +2385,128 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) + setGovernanceContact(null)} + closeDisabled={saving} + className="address-governance-dialog" + footerClassName="button-row compact-actions" + footer={}> +
+
+
+
+ Recorded facts +

History is retained. End a fact when it no longer applies.

+
+
+
+ {channelRules.length === 0 ? +

No communication-governance facts recorded.

: + channelRules.map((rule) => { + const state = channelRuleState(rule); + return ( +
+
+ + {rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")} + + + {rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""} + + {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)}` : ""} + +
+ {state !== "ended" && + + } +
+ ); + }) + } +
+
+ + {canWriteGovernance && +
void submitChannelRule(event)}> +
+
+ Record a fact +

Addresses records evidence; Policy may apply stricter delivery rules.

+
+ +
+
+ + + + + + +
+
+ + + + + setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} /> + +
+
+ setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /> + setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /> + setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /> +
+
+ setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /> + setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /> + setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /> +
+