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