Add governed contact channel facts
This commit is contained in:
@@ -1,25 +1,44 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.distribution_lists import (
|
||||||
|
DistributionChannelCandidate,
|
||||||
|
DistributionExplanation,
|
||||||
|
DistributionSourceReference,
|
||||||
|
RecipientChannelFacts,
|
||||||
|
RecipientChannelFactsRequest,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_core.core.people import (
|
from govoplan_core.core.people import (
|
||||||
PeopleSearchGroup,
|
PeopleSearchGroup,
|
||||||
PersonSearchCandidate,
|
PersonSearchCandidate,
|
||||||
person_selection_key,
|
person_selection_key,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
from govoplan_addresses.backend.db.models import (
|
||||||
|
AddressBook,
|
||||||
|
AddressList,
|
||||||
|
AddressListEntry,
|
||||||
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
|
ContactEmail,
|
||||||
|
ContactPhone,
|
||||||
|
ContactPostalAddress,
|
||||||
|
)
|
||||||
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
AddressBookError,
|
AddressBookError,
|
||||||
create_contact,
|
create_contact,
|
||||||
get_visible_address_book,
|
get_visible_address_book,
|
||||||
get_visible_address_list,
|
get_visible_address_list,
|
||||||
|
get_visible_contact,
|
||||||
list_address_books,
|
list_address_books,
|
||||||
list_address_list_entries,
|
list_address_list_entries,
|
||||||
list_address_lists,
|
list_address_lists,
|
||||||
@@ -60,6 +79,19 @@ class RecipientSnapshotItem:
|
|||||||
provenance: dict[str, Any] = field(default_factory=dict)
|
provenance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecipientSnapshotExcludedItem:
|
||||||
|
contact_id: str
|
||||||
|
display_name: str
|
||||||
|
channel: str
|
||||||
|
target: str
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
status: str = "suppressed"
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
provenance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RecipientSourceRef:
|
class RecipientSourceRef:
|
||||||
source_id: str
|
source_id: str
|
||||||
@@ -78,6 +110,9 @@ class RecipientSourceSnapshot:
|
|||||||
source_revision: str
|
source_revision: str
|
||||||
generated_at: str
|
generated_at: str
|
||||||
recipients: tuple[RecipientSnapshotItem, ...]
|
recipients: tuple[RecipientSnapshotItem, ...]
|
||||||
|
excluded: tuple[RecipientSnapshotExcludedItem, ...] = ()
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[str, ...] = ()
|
||||||
provenance: dict[str, Any] = field(default_factory=dict)
|
provenance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -283,13 +318,27 @@ class AddressesRecipientSourceCapability:
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
*,
|
*,
|
||||||
address_book_id: str,
|
address_book_id: str,
|
||||||
|
purpose: str | None = None,
|
||||||
|
requested_channels: tuple[str, ...] = ("email",),
|
||||||
) -> RecipientSourceSnapshot:
|
) -> RecipientSourceSnapshot:
|
||||||
book = get_visible_address_book(session, principal, address_book_id)
|
book = get_visible_address_book(session, principal, address_book_id)
|
||||||
contacts = _active_address_book_contacts(session, book.id)
|
contacts = _active_address_book_contacts(session, book.id)
|
||||||
recipients: list[RecipientSnapshotItem] = []
|
recipients: list[RecipientSnapshotItem] = []
|
||||||
|
excluded: list[RecipientSnapshotExcludedItem] = []
|
||||||
|
if purpose is None:
|
||||||
for contact in contacts:
|
for contact in contacts:
|
||||||
for email in contact.emails:
|
for email in contact.emails:
|
||||||
recipients.append(_recipient_snapshot_item(contact, email))
|
recipients.append(_recipient_snapshot_item(contact, email))
|
||||||
|
else:
|
||||||
|
for contact in contacts:
|
||||||
|
included_rows, excluded_rows = _governed_email_snapshot_rows(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
contact,
|
||||||
|
purpose=purpose,
|
||||||
|
)
|
||||||
|
recipients.extend(included_rows)
|
||||||
|
excluded.extend(excluded_rows)
|
||||||
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
|
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
|
||||||
return RecipientSourceSnapshot(
|
return RecipientSourceSnapshot(
|
||||||
source_id=f"addresses:address_book:{book.id}",
|
source_id=f"addresses:address_book:{book.id}",
|
||||||
@@ -298,12 +347,18 @@ class AddressesRecipientSourceCapability:
|
|||||||
source_revision=revision,
|
source_revision=revision,
|
||||||
generated_at=utcnow().isoformat(),
|
generated_at=utcnow().isoformat(),
|
||||||
recipients=tuple(recipients),
|
recipients=tuple(recipients),
|
||||||
|
excluded=tuple(excluded),
|
||||||
|
purpose=purpose,
|
||||||
|
requested_channels=requested_channels,
|
||||||
provenance={
|
provenance={
|
||||||
"module": "addresses",
|
"module": "addresses",
|
||||||
"address_book_id": book.id,
|
"address_book_id": book.id,
|
||||||
"scope_type": book.scope_type,
|
"scope_type": book.scope_type,
|
||||||
"scope_id": book.scope_id,
|
"scope_id": book.scope_id,
|
||||||
"tenant_id": book.tenant_id,
|
"tenant_id": book.tenant_id,
|
||||||
|
"governance_applied": purpose is not None,
|
||||||
|
"included_count": len(recipients),
|
||||||
|
"excluded_count": len(excluded),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,15 +368,31 @@ class AddressesRecipientSourceCapability:
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
*,
|
*,
|
||||||
address_list_id: str,
|
address_list_id: str,
|
||||||
|
purpose: str | None = None,
|
||||||
|
requested_channels: tuple[str, ...] = ("email",),
|
||||||
) -> RecipientSourceSnapshot:
|
) -> RecipientSourceSnapshot:
|
||||||
address_list = get_visible_address_list(session, principal, address_list_id)
|
address_list = get_visible_address_list(session, principal, address_list_id)
|
||||||
entries = list_address_list_entries(session, principal, address_list.id)
|
entries = list_address_list_entries(session, principal, address_list.id)
|
||||||
recipients: list[RecipientSnapshotItem] = []
|
recipients: list[RecipientSnapshotItem] = []
|
||||||
|
excluded: list[RecipientSnapshotExcludedItem] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
email = _entry_email(entry)
|
email = _entry_email(entry)
|
||||||
if email is None:
|
if email is None:
|
||||||
continue
|
continue
|
||||||
|
if purpose is None:
|
||||||
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
|
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
|
||||||
|
continue
|
||||||
|
included_rows, excluded_rows = _governed_email_snapshot_rows(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
entry.contact,
|
||||||
|
purpose=purpose,
|
||||||
|
email_ids={email.id},
|
||||||
|
address_list=address_list,
|
||||||
|
address_list_entry=entry,
|
||||||
|
)
|
||||||
|
recipients.extend(included_rows)
|
||||||
|
excluded.extend(excluded_rows)
|
||||||
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
|
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
|
||||||
return RecipientSourceSnapshot(
|
return RecipientSourceSnapshot(
|
||||||
source_id=f"addresses:address_list:{address_list.id}",
|
source_id=f"addresses:address_list:{address_list.id}",
|
||||||
@@ -330,6 +401,9 @@ class AddressesRecipientSourceCapability:
|
|||||||
source_revision=revision,
|
source_revision=revision,
|
||||||
generated_at=utcnow().isoformat(),
|
generated_at=utcnow().isoformat(),
|
||||||
recipients=tuple(recipients),
|
recipients=tuple(recipients),
|
||||||
|
excluded=tuple(excluded),
|
||||||
|
purpose=purpose,
|
||||||
|
requested_channels=requested_channels,
|
||||||
provenance={
|
provenance={
|
||||||
"module": "addresses",
|
"module": "addresses",
|
||||||
"address_book_id": address_list.address_book_id,
|
"address_book_id": address_list.address_book_id,
|
||||||
@@ -337,6 +411,9 @@ class AddressesRecipientSourceCapability:
|
|||||||
"scope_type": address_list.address_book.scope_type,
|
"scope_type": address_list.address_book.scope_type,
|
||||||
"scope_id": address_list.address_book.scope_id,
|
"scope_id": address_list.address_book.scope_id,
|
||||||
"tenant_id": address_list.tenant_id,
|
"tenant_id": address_list.tenant_id,
|
||||||
|
"governance_applied": purpose is not None,
|
||||||
|
"included_count": len(recipients),
|
||||||
|
"excluded_count": len(excluded),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,16 +423,157 @@ class AddressesRecipientSourceCapability:
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
*,
|
*,
|
||||||
source_id: str,
|
source_id: str,
|
||||||
|
purpose: str | None = None,
|
||||||
|
requested_channels: tuple[str, ...] = ("email",),
|
||||||
) -> RecipientSourceSnapshot:
|
) -> RecipientSourceSnapshot:
|
||||||
book_prefix = "addresses:address_book:"
|
book_prefix = "addresses:address_book:"
|
||||||
if source_id.startswith(book_prefix):
|
if source_id.startswith(book_prefix):
|
||||||
return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix))
|
return self.snapshot_address_book(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_book_id=source_id.removeprefix(book_prefix),
|
||||||
|
purpose=purpose,
|
||||||
|
requested_channels=requested_channels,
|
||||||
|
)
|
||||||
list_prefix = "addresses:address_list:"
|
list_prefix = "addresses:address_list:"
|
||||||
if source_id.startswith(list_prefix):
|
if source_id.startswith(list_prefix):
|
||||||
return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix))
|
return self.snapshot_address_list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_list_id=source_id.removeprefix(list_prefix),
|
||||||
|
purpose=purpose,
|
||||||
|
requested_channels=requested_channels,
|
||||||
|
)
|
||||||
raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
|
raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesChannelFactsCapability:
|
||||||
|
"""Resolve effective contact-point facts without making a Policy decision."""
|
||||||
|
|
||||||
|
def resolve_channel_facts(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
request: RecipientChannelFactsRequest,
|
||||||
|
) -> RecipientChannelFacts:
|
||||||
|
if request.tenant_id != principal.tenant_id:
|
||||||
|
raise PermissionError("Channel facts cannot be resolved across tenants.")
|
||||||
|
contact_id = _channel_facts_contact_id(request.source)
|
||||||
|
contact = get_visible_contact(session, principal, contact_id)
|
||||||
|
effective_at = _aware_datetime(request.effective_at)
|
||||||
|
active_rules = [
|
||||||
|
rule
|
||||||
|
for rule in contact.channel_rules
|
||||||
|
if _rule_matches_purpose(rule, request.purpose)
|
||||||
|
and _rule_is_effective(rule, effective_at)
|
||||||
|
]
|
||||||
|
expired_rules = [
|
||||||
|
rule
|
||||||
|
for rule in contact.channel_rules
|
||||||
|
if _rule_matches_purpose(rule, request.purpose)
|
||||||
|
and rule.effective_until is not None
|
||||||
|
and _aware_datetime(rule.effective_until) <= effective_at
|
||||||
|
]
|
||||||
|
revision, fingerprint = _contact_channel_revision(contact)
|
||||||
|
source = DistributionSourceReference(
|
||||||
|
provider="addresses",
|
||||||
|
resource_type="contact",
|
||||||
|
resource_id=contact.id,
|
||||||
|
revision=revision,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
label=contact.display_name,
|
||||||
|
metadata={
|
||||||
|
"address_book_id": contact.address_book_id,
|
||||||
|
"source_kind": contact.source_kind,
|
||||||
|
"source_ref": contact.source_ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
requested_channels = set(request.requested_channels)
|
||||||
|
candidates: list[DistributionChannelCandidate] = []
|
||||||
|
for channel, point_id, target, target_key, intrinsically_valid in _contact_channel_points(contact):
|
||||||
|
if requested_channels and channel not in requested_channels:
|
||||||
|
continue
|
||||||
|
matching = [
|
||||||
|
rule
|
||||||
|
for rule in active_rules
|
||||||
|
if rule.channel == channel
|
||||||
|
and (rule.contact_point_id is None or rule.contact_point_id == point_id)
|
||||||
|
]
|
||||||
|
selected = max(matching, key=_channel_rule_priority, default=None)
|
||||||
|
state = selected.decision if selected is not None else "unknown"
|
||||||
|
status = _channel_candidate_status(state, intrinsically_valid=intrinsically_valid)
|
||||||
|
reason_code = (
|
||||||
|
f"addresses.channel.{state}"
|
||||||
|
if intrinsically_valid
|
||||||
|
else f"addresses.{channel}.invalid"
|
||||||
|
)
|
||||||
|
explanation = (
|
||||||
|
_channel_rule_explanation(selected)
|
||||||
|
if selected is not None
|
||||||
|
else (
|
||||||
|
f"The {channel.replace('_', ' ')} contact point has no applicable governance fact."
|
||||||
|
if intrinsically_valid
|
||||||
|
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
DistributionChannelCandidate(
|
||||||
|
channel=channel,
|
||||||
|
target=target,
|
||||||
|
target_key=target_key,
|
||||||
|
status=status,
|
||||||
|
contact_point_id=point_id,
|
||||||
|
locale=selected.locale if selected is not None else None,
|
||||||
|
preferred=any(rule.decision == "preferred" for rule in matching),
|
||||||
|
reason_code=reason_code,
|
||||||
|
explanation=explanation,
|
||||||
|
source=source,
|
||||||
|
decision_provenance={
|
||||||
|
"provider": "addresses",
|
||||||
|
"governance_state": state,
|
||||||
|
"effective_at": effective_at.isoformat(),
|
||||||
|
"purpose": request.purpose,
|
||||||
|
"selected_rule_id": selected.id if selected is not None else None,
|
||||||
|
"rule_ids": [rule.id for rule in matching],
|
||||||
|
"legal_basis": selected.legal_basis if selected is not None else None,
|
||||||
|
"evidence_ref": selected.evidence_ref if selected is not None else None,
|
||||||
|
"preference_rank": selected.preference_rank if selected is not None else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
explanations = tuple(
|
||||||
|
DistributionExplanation(
|
||||||
|
code="addresses.channel_fact.expired",
|
||||||
|
message=(
|
||||||
|
f"A {rule.channel.replace('_', ' ')} governance fact expired and was not applied."
|
||||||
|
),
|
||||||
|
severity="info",
|
||||||
|
provider="addresses",
|
||||||
|
source=source,
|
||||||
|
provenance={"rule_id": rule.id, "decision": rule.decision},
|
||||||
|
)
|
||||||
|
for rule in expired_rules
|
||||||
|
)
|
||||||
|
return RecipientChannelFacts(
|
||||||
|
candidates=tuple(candidates),
|
||||||
|
explanations=explanations,
|
||||||
|
source_revision=revision,
|
||||||
|
source_fingerprint=fingerprint,
|
||||||
|
provenance={
|
||||||
|
"module": "addresses",
|
||||||
|
"contact_id": contact.id,
|
||||||
|
"address_book_id": contact.address_book_id,
|
||||||
|
"effective_at": effective_at.isoformat(),
|
||||||
|
"purpose": request.purpose,
|
||||||
|
"active_rule_ids": [rule.id for rule in active_rules],
|
||||||
|
"expired_rule_ids": [rule.id for rule in expired_rules],
|
||||||
|
"source_revision": revision,
|
||||||
|
"source_fingerprint": fingerprint,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
||||||
return AddressesLookupCapability()
|
return AddressesLookupCapability()
|
||||||
|
|
||||||
@@ -368,6 +586,10 @@ def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapabi
|
|||||||
return AddressesRecipientSourceCapability()
|
return AddressesRecipientSourceCapability()
|
||||||
|
|
||||||
|
|
||||||
|
def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability:
|
||||||
|
return AddressesChannelFactsCapability()
|
||||||
|
|
||||||
|
|
||||||
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
|
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
|
||||||
return AddressesContactWriterCapability()
|
return AddressesContactWriterCapability()
|
||||||
|
|
||||||
@@ -484,6 +706,7 @@ def _recipient_snapshot_item(
|
|||||||
*,
|
*,
|
||||||
address_list: AddressList | None = None,
|
address_list: AddressList | None = None,
|
||||||
address_list_entry: AddressListEntry | None = None,
|
address_list_entry: AddressListEntry | None = None,
|
||||||
|
channel_decision: dict[str, Any] | None = None,
|
||||||
) -> RecipientSnapshotItem:
|
) -> RecipientSnapshotItem:
|
||||||
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
|
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
|
||||||
provenance = {
|
provenance = {
|
||||||
@@ -501,6 +724,8 @@ def _recipient_snapshot_item(
|
|||||||
"address_list_entry_kind": address_list_entry.target_kind,
|
"address_list_entry_kind": address_list_entry.target_kind,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if channel_decision is not None:
|
||||||
|
provenance["channel_decision"] = dict(channel_decision)
|
||||||
return RecipientSnapshotItem(
|
return RecipientSnapshotItem(
|
||||||
contact_id=contact.id,
|
contact_id=contact.id,
|
||||||
display_name=contact.display_name,
|
display_name=contact.display_name,
|
||||||
@@ -518,6 +743,91 @@ def _recipient_snapshot_item(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _governed_email_snapshot_rows(
|
||||||
|
session: Any,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
contact: Contact,
|
||||||
|
*,
|
||||||
|
purpose: str,
|
||||||
|
email_ids: set[str] | None = None,
|
||||||
|
address_list: AddressList | None = None,
|
||||||
|
address_list_entry: AddressListEntry | None = None,
|
||||||
|
) -> tuple[list[RecipientSnapshotItem], list[RecipientSnapshotExcludedItem]]:
|
||||||
|
effective_at = utcnow()
|
||||||
|
facts = AddressesChannelFactsCapability().resolve_channel_facts(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=RecipientChannelFactsRequest(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source=DistributionSourceReference(
|
||||||
|
provider="addresses",
|
||||||
|
resource_type="contact",
|
||||||
|
resource_id=contact.id,
|
||||||
|
),
|
||||||
|
recipient_key=f"contact:{contact.id}",
|
||||||
|
effective_at=effective_at,
|
||||||
|
purpose=purpose,
|
||||||
|
requested_channels=("email",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
emails_by_id = {item.id: item for item in contact.emails}
|
||||||
|
included: list[RecipientSnapshotItem] = []
|
||||||
|
excluded: list[RecipientSnapshotExcludedItem] = []
|
||||||
|
for candidate in facts.candidates:
|
||||||
|
if candidate.channel != "email":
|
||||||
|
continue
|
||||||
|
point_id = candidate.contact_point_id
|
||||||
|
if email_ids is not None and point_id not in email_ids:
|
||||||
|
continue
|
||||||
|
email = emails_by_id.get(point_id or "")
|
||||||
|
if email is None:
|
||||||
|
continue
|
||||||
|
decision = {
|
||||||
|
**dict(candidate.decision_provenance),
|
||||||
|
"status": candidate.status,
|
||||||
|
"reason_code": candidate.reason_code,
|
||||||
|
"explanation": candidate.explanation,
|
||||||
|
"source_revision": facts.source_revision,
|
||||||
|
"source_fingerprint": facts.source_fingerprint,
|
||||||
|
}
|
||||||
|
if candidate.status in {"usable", "stale"}:
|
||||||
|
included.append(
|
||||||
|
_recipient_snapshot_item(
|
||||||
|
contact,
|
||||||
|
email,
|
||||||
|
address_list=address_list,
|
||||||
|
address_list_entry=address_list_entry,
|
||||||
|
channel_decision=decision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
provenance = {
|
||||||
|
**_contact_provenance(contact),
|
||||||
|
"channel_decision": decision,
|
||||||
|
}
|
||||||
|
if address_list is not None and address_list_entry is not None:
|
||||||
|
provenance.update(
|
||||||
|
{
|
||||||
|
"address_list_id": address_list.id,
|
||||||
|
"address_list_entry_id": address_list_entry.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
excluded.append(
|
||||||
|
RecipientSnapshotExcludedItem(
|
||||||
|
contact_id=contact.id,
|
||||||
|
display_name=contact.display_name,
|
||||||
|
channel="email",
|
||||||
|
target=email.email,
|
||||||
|
contact_point_id=email.id,
|
||||||
|
status=candidate.status,
|
||||||
|
reason_code=candidate.reason_code,
|
||||||
|
explanation=candidate.explanation,
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return included, excluded
|
||||||
|
|
||||||
|
|
||||||
def _contact_provenance(contact: Contact) -> dict[str, Any]:
|
def _contact_provenance(contact: Contact) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"module": "addresses",
|
"module": "addresses",
|
||||||
@@ -638,7 +948,21 @@ def _address_book_contact_updated_at(session: Any, address_book_ids: list[str])
|
|||||||
.group_by(Contact.address_book_id)
|
.group_by(Contact.address_book_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows]:
|
postal_rows = (
|
||||||
|
session.query(Contact.address_book_id, func.max(ContactPostalAddress.updated_at))
|
||||||
|
.join(Contact, ContactPostalAddress.contact_id == Contact.id)
|
||||||
|
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
||||||
|
.group_by(Contact.address_book_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
rule_rows = (
|
||||||
|
session.query(Contact.address_book_id, func.max(ContactChannelRule.updated_at))
|
||||||
|
.join(Contact, ContactChannelRule.contact_id == Contact.id)
|
||||||
|
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
||||||
|
.group_by(Contact.address_book_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows, *postal_rows, *rule_rows]:
|
||||||
if updated_at is None:
|
if updated_at is None:
|
||||||
continue
|
continue
|
||||||
key = str(address_book_id)
|
key = str(address_book_id)
|
||||||
@@ -670,7 +994,14 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
|
|||||||
.group_by(AddressListEntry.address_list_id)
|
.group_by(AddressListEntry.address_list_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows]:
|
rule_rows = (
|
||||||
|
session.query(AddressListEntry.address_list_id, func.max(ContactChannelRule.updated_at))
|
||||||
|
.join(ContactChannelRule, AddressListEntry.contact_id == ContactChannelRule.contact_id)
|
||||||
|
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
|
||||||
|
.group_by(AddressListEntry.address_list_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows, *rule_rows]:
|
||||||
if updated_at is None:
|
if updated_at is None:
|
||||||
continue
|
continue
|
||||||
key = str(address_list_id)
|
key = str(address_list_id)
|
||||||
@@ -690,3 +1021,150 @@ def _address_list_source_revision(address_list: AddressList, latest_entry_update
|
|||||||
if latest_entry_updated_at is not None:
|
if latest_entry_updated_at is not None:
|
||||||
stamps.append(latest_entry_updated_at)
|
stamps.append(latest_entry_updated_at)
|
||||||
return max(stamps).isoformat()
|
return max(stamps).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_facts_contact_id(source: DistributionSourceReference) -> str:
|
||||||
|
metadata_contact_id = source.metadata.get("contact_id")
|
||||||
|
if metadata_contact_id:
|
||||||
|
return str(metadata_contact_id)
|
||||||
|
if source.resource_type not in {"contact", "address_contact", "address_email"}:
|
||||||
|
raise ValueError(f"Unsupported Addresses channel-facts source: {source.resource_type}.")
|
||||||
|
return source.resource_id
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_channel_points(
|
||||||
|
contact: Contact,
|
||||||
|
) -> tuple[tuple[str, str | None, str, str, bool], ...]:
|
||||||
|
points: list[tuple[str, str | None, str, str, bool]] = []
|
||||||
|
for email in contact.emails:
|
||||||
|
target = email.email.strip()
|
||||||
|
valid = bool(target and "@" in target and len(target) <= 320)
|
||||||
|
points.append(("email", email.id, target, f"email:{target.casefold()}", valid))
|
||||||
|
for address in contact.postal_addresses:
|
||||||
|
target = ", ".join(
|
||||||
|
item
|
||||||
|
for item in (
|
||||||
|
address.street,
|
||||||
|
" ".join(part for part in (address.postal_code, address.locality) if part),
|
||||||
|
address.region,
|
||||||
|
address.country,
|
||||||
|
)
|
||||||
|
if item
|
||||||
|
)
|
||||||
|
points.append(("postal", address.id, target, f"postal:{' '.join(target.casefold().split())}", bool(target)))
|
||||||
|
provenance = dict(contact.provenance or {})
|
||||||
|
internal_target = provenance.get("internal_mail_account_id") or provenance.get("account_id")
|
||||||
|
if internal_target:
|
||||||
|
target = str(internal_target).strip()
|
||||||
|
points.append(("internal_mail", None, target, f"internal_mail:{target.casefold()}", bool(target)))
|
||||||
|
portal_target = provenance.get("portal_target") or provenance.get("portal_account_id")
|
||||||
|
if portal_target:
|
||||||
|
target = str(portal_target).strip()
|
||||||
|
points.append(("portal", None, target, f"portal:{target.casefold()}", bool(target)))
|
||||||
|
return tuple(points)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_matches_purpose(rule: ContactChannelRule, purpose: str | None) -> bool:
|
||||||
|
return rule.purpose is None or (purpose is not None and rule.purpose == purpose)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_is_effective(rule: ContactChannelRule, effective_at: datetime) -> bool:
|
||||||
|
return (
|
||||||
|
(rule.effective_from is None or _aware_datetime(rule.effective_from) <= effective_at)
|
||||||
|
and (rule.effective_until is None or _aware_datetime(rule.effective_until) > effective_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_rule_priority(rule: ContactChannelRule) -> tuple[int, int, int, datetime]:
|
||||||
|
decision_priority = {
|
||||||
|
"invalid": 90,
|
||||||
|
"returned": 80,
|
||||||
|
"suppressed": 70,
|
||||||
|
"opted_out": 60,
|
||||||
|
"temporarily_unavailable": 50,
|
||||||
|
"preferred": 30,
|
||||||
|
"opted_in": 20,
|
||||||
|
"allowed": 10,
|
||||||
|
}.get(rule.decision, 0)
|
||||||
|
return (
|
||||||
|
decision_priority,
|
||||||
|
int(rule.contact_point_id is not None),
|
||||||
|
int(rule.purpose is not None),
|
||||||
|
_aware_datetime(rule.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_candidate_status(state: str, *, intrinsically_valid: bool) -> str:
|
||||||
|
if not intrinsically_valid or state in {"invalid", "returned"}:
|
||||||
|
return "invalid"
|
||||||
|
if state in {"suppressed", "opted_out", "temporarily_unavailable"}:
|
||||||
|
return "suppressed"
|
||||||
|
return "usable"
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_rule_explanation(rule: ContactChannelRule) -> str:
|
||||||
|
if rule.reason:
|
||||||
|
return rule.reason
|
||||||
|
return {
|
||||||
|
"allowed": "Use of this contact point is explicitly allowed.",
|
||||||
|
"opted_in": "The contact opted in to this communication channel.",
|
||||||
|
"preferred": "This is a preferred communication channel.",
|
||||||
|
"opted_out": "The contact opted out of this communication channel.",
|
||||||
|
"suppressed": "This contact point is suppressed.",
|
||||||
|
"invalid": "This contact point is marked invalid.",
|
||||||
|
"returned": "Delivery to this contact point was returned.",
|
||||||
|
"temporarily_unavailable": "This contact point is temporarily unavailable.",
|
||||||
|
}.get(rule.decision, "An Addresses governance fact was applied.")
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
|
||||||
|
stamps = [contact.updated_at]
|
||||||
|
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
|
||||||
|
revision = max(_aware_datetime(item) for item in stamps).isoformat()
|
||||||
|
payload = {
|
||||||
|
"contact_id": contact.id,
|
||||||
|
"revision": revision,
|
||||||
|
"emails": [
|
||||||
|
{"id": item.id, "email": item.email, "primary": item.is_primary}
|
||||||
|
for item in contact.emails
|
||||||
|
],
|
||||||
|
"postal": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"street": item.street,
|
||||||
|
"postal_code": item.postal_code,
|
||||||
|
"locality": item.locality,
|
||||||
|
"region": item.region,
|
||||||
|
"country": item.country,
|
||||||
|
"primary": item.is_primary,
|
||||||
|
}
|
||||||
|
for item in contact.postal_addresses
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": rule.id,
|
||||||
|
"channel": rule.channel,
|
||||||
|
"purpose": rule.purpose,
|
||||||
|
"point": rule.contact_point_id,
|
||||||
|
"decision": rule.decision,
|
||||||
|
"basis": rule.legal_basis,
|
||||||
|
"evidence": rule.evidence_ref,
|
||||||
|
"rank": rule.preference_rank,
|
||||||
|
"locale": rule.locale,
|
||||||
|
"from": rule.effective_from.isoformat() if rule.effective_from else None,
|
||||||
|
"until": rule.effective_until.isoformat() if rule.effective_until else None,
|
||||||
|
"updated": rule.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return revision, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def _aware_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
return value
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ class Contact(Base, TimestampMixin):
|
|||||||
order_by="ContactPostalAddress.order_index",
|
order_by="ContactPostalAddress.order_index",
|
||||||
)
|
)
|
||||||
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
|
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
|
||||||
|
channel_rules: Mapped[list["ContactChannelRule"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ContactChannelRule.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactEmail(Base, TimestampMixin):
|
class ContactEmail(Base, TimestampMixin):
|
||||||
@@ -145,6 +150,47 @@ class ContactPostalAddress(Base, TimestampMixin):
|
|||||||
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
|
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRule(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_channel_rules"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_addresses_channel_rules_resolution",
|
||||||
|
"tenant_id",
|
||||||
|
"contact_id",
|
||||||
|
"channel",
|
||||||
|
"purpose",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_addresses_channel_rules_effective",
|
||||||
|
"effective_from",
|
||||||
|
"effective_until",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||||
|
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
decision: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
legal_basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
preference_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
locale: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="channel_rules")
|
||||||
|
|
||||||
|
|
||||||
class AddressList(Base, TimestampMixin):
|
class AddressList(Base, TimestampMixin):
|
||||||
__tablename__ = "addresses_address_lists"
|
__tablename__ = "addresses_address_lists"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from govoplan_addresses.backend.db import models as addresses_models # noqa: F4
|
|||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||||
|
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -39,6 +40,7 @@ _addresses_table_retirement_provider = drop_table_retirement_provider(
|
|||||||
addresses_models.ContactPostalAddress,
|
addresses_models.ContactPostalAddress,
|
||||||
addresses_models.ContactPhone,
|
addresses_models.ContactPhone,
|
||||||
addresses_models.ContactEmail,
|
addresses_models.ContactEmail,
|
||||||
|
addresses_models.ContactChannelRule,
|
||||||
addresses_models.Contact,
|
addresses_models.Contact,
|
||||||
addresses_models.AddressBook,
|
addresses_models.AddressBook,
|
||||||
label="Addresses",
|
label="Addresses",
|
||||||
@@ -95,6 +97,8 @@ PERMISSIONS = (
|
|||||||
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
|
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
|
||||||
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
|
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
|
||||||
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
|
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
|
||||||
|
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
|
||||||
|
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
|
||||||
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
|
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
|
||||||
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
|
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
|
||||||
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
|
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
|
||||||
@@ -116,6 +120,8 @@ ROLE_TEMPLATES = (
|
|||||||
"addresses:contact:read",
|
"addresses:contact:read",
|
||||||
"addresses:contact:write",
|
"addresses:contact:write",
|
||||||
"addresses:contact:delete",
|
"addresses:contact:delete",
|
||||||
|
"addresses:governance:read",
|
||||||
|
"addresses:governance:write",
|
||||||
"addresses:sync:read",
|
"addresses:sync:read",
|
||||||
"addresses:sync:write",
|
"addresses:sync:write",
|
||||||
),
|
),
|
||||||
@@ -124,7 +130,7 @@ ROLE_TEMPLATES = (
|
|||||||
slug="address_book_reader",
|
slug="address_book_reader",
|
||||||
name="Address book reader",
|
name="Address book reader",
|
||||||
description="Read visible address books and contacts.",
|
description="Read visible address books and contacts.",
|
||||||
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"),
|
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,8 +161,9 @@ manifest = ModuleManifest(
|
|||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_addresses_router,
|
route_factory=_addresses_router,
|
||||||
@@ -188,6 +195,10 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["contact_writer_capability"],
|
fromlist=["contact_writer_capability"],
|
||||||
).contact_writer_capability(context),
|
).contact_writer_capability(context),
|
||||||
|
CAPABILITY_RECIPIENT_CHANNEL_FACTS: lambda context: __import__(
|
||||||
|
"govoplan_addresses.backend.capabilities",
|
||||||
|
fromlist=["channel_facts_capability"],
|
||||||
|
).channel_facts_capability(context),
|
||||||
},
|
},
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
@@ -202,6 +213,7 @@ manifest = ModuleManifest(
|
|||||||
addresses_models.ContactEmail,
|
addresses_models.ContactEmail,
|
||||||
addresses_models.ContactPhone,
|
addresses_models.ContactPhone,
|
||||||
addresses_models.ContactPostalAddress,
|
addresses_models.ContactPostalAddress,
|
||||||
|
addresses_models.ContactChannelRule,
|
||||||
label="Addresses",
|
label="Addresses",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+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,
|
AddressSyncSource,
|
||||||
AddressSyncTombstone,
|
AddressSyncTombstone,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
|
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
|
||||||
@@ -62,6 +63,9 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
AddressSyncTombstoneListResponse,
|
AddressSyncTombstoneListResponse,
|
||||||
AddressSyncTombstoneResponse,
|
AddressSyncTombstoneResponse,
|
||||||
ContactCreateRequest,
|
ContactCreateRequest,
|
||||||
|
ContactChannelRuleCreateRequest,
|
||||||
|
ContactChannelRuleListResponse,
|
||||||
|
ContactChannelRuleResponse,
|
||||||
ContactListResponse,
|
ContactListResponse,
|
||||||
ContactResponse,
|
ContactResponse,
|
||||||
ContactUpdateRequest,
|
ContactUpdateRequest,
|
||||||
@@ -79,6 +83,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_address_list_entry,
|
create_address_list_entry,
|
||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
|
create_contact_channel_rule,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
count_contacts,
|
count_contacts,
|
||||||
delete_address_book,
|
delete_address_book,
|
||||||
@@ -89,12 +94,14 @@ from govoplan_addresses.backend.service import (
|
|||||||
discover_carddav_address_books,
|
discover_carddav_address_books,
|
||||||
export_address_book_vcard,
|
export_address_book_vcard,
|
||||||
export_contact_vcard,
|
export_contact_vcard,
|
||||||
|
end_contact_channel_rule,
|
||||||
finish_sync_attempt,
|
finish_sync_attempt,
|
||||||
import_vcards,
|
import_vcards,
|
||||||
list_address_list_entries,
|
list_address_list_entries,
|
||||||
list_address_lists,
|
list_address_lists,
|
||||||
list_address_books,
|
list_address_books,
|
||||||
list_contacts,
|
list_contacts,
|
||||||
|
list_contact_channel_rules,
|
||||||
list_sync_conflicts,
|
list_sync_conflicts,
|
||||||
list_sync_diagnostics,
|
list_sync_diagnostics,
|
||||||
list_sync_sources,
|
list_sync_sources,
|
||||||
@@ -156,6 +163,10 @@ def _contact_response(contact: Contact) -> ContactResponse:
|
|||||||
return ContactResponse.model_validate(contact)
|
return ContactResponse.model_validate(contact)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
|
||||||
|
return ContactChannelRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
|
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
|
||||||
return AddressListResponse.model_validate(
|
return AddressListResponse.model_validate(
|
||||||
{
|
{
|
||||||
@@ -1081,6 +1092,97 @@ def api_update_contact(
|
|||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleListResponse)
|
||||||
|
def api_list_contact_channel_rules(
|
||||||
|
contact_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "addresses:governance:read")
|
||||||
|
try:
|
||||||
|
return ContactChannelRuleListResponse(
|
||||||
|
rules=[
|
||||||
|
_channel_rule_response(rule)
|
||||||
|
for rule in list_contact_channel_rules(session, principal, contact_id)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
except AddressBookError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/contacts/{contact_id}/channel-rules",
|
||||||
|
response_model=ContactChannelRuleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_contact_channel_rule(
|
||||||
|
contact_id: str,
|
||||||
|
payload: ContactChannelRuleCreateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "addresses:governance:write")
|
||||||
|
try:
|
||||||
|
rule = create_contact_channel_rule(session, principal, contact_id, payload)
|
||||||
|
_audit_address_sync(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="addresses.channel_rule_created",
|
||||||
|
object_type="address_contact_channel_rule",
|
||||||
|
object_id=rule.id,
|
||||||
|
details={
|
||||||
|
"contact_id": contact_id,
|
||||||
|
"channel": rule.channel,
|
||||||
|
"purpose": rule.purpose,
|
||||||
|
"contact_point_id": rule.contact_point_id,
|
||||||
|
"decision": rule.decision,
|
||||||
|
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
|
||||||
|
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
|
||||||
|
"evidence_ref": rule.evidence_ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(rule)
|
||||||
|
return _channel_rule_response(rule)
|
||||||
|
except AddressBookError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/contact-channel-rules/{rule_id}",
|
||||||
|
response_model=ContactChannelRuleResponse,
|
||||||
|
)
|
||||||
|
def api_end_contact_channel_rule(
|
||||||
|
rule_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "addresses:governance:write")
|
||||||
|
try:
|
||||||
|
rule = end_contact_channel_rule(session, principal, rule_id)
|
||||||
|
_audit_address_sync(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="addresses.channel_rule_ended",
|
||||||
|
object_type="address_contact_channel_rule",
|
||||||
|
object_id=rule.id,
|
||||||
|
details={
|
||||||
|
"contact_id": rule.contact_id,
|
||||||
|
"channel": rule.channel,
|
||||||
|
"purpose": rule.purpose,
|
||||||
|
"decision": rule.decision,
|
||||||
|
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(rule)
|
||||||
|
return _channel_rule_response(rule)
|
||||||
|
except AddressBookError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def api_delete_contact(
|
def api_delete_contact(
|
||||||
contact_id: str,
|
contact_id: str,
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
|
|||||||
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
|
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
|
||||||
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
||||||
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
||||||
|
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
||||||
|
AddressChannelDecision = Literal[
|
||||||
|
"allowed",
|
||||||
|
"opted_in",
|
||||||
|
"preferred",
|
||||||
|
"opted_out",
|
||||||
|
"suppressed",
|
||||||
|
"invalid",
|
||||||
|
"returned",
|
||||||
|
"temporarily_unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ContactEmailPayload(BaseModel):
|
class ContactEmailPayload(BaseModel):
|
||||||
@@ -227,6 +238,50 @@ class ContactListResponse(BaseModel):
|
|||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
channel: AddressDistributionChannel
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
contact_point_id: str | None = Field(default=None, max_length=36)
|
||||||
|
decision: AddressChannelDecision
|
||||||
|
legal_basis: str | None = Field(default=None, max_length=255)
|
||||||
|
evidence_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
reason: str | None = None
|
||||||
|
preference_rank: int | None = Field(default=None, ge=0, le=10000)
|
||||||
|
locale: str | None = Field(default=None, max_length=20)
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
contact_id: str
|
||||||
|
channel: AddressDistributionChannel
|
||||||
|
purpose: str | None = None
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
decision: AddressChannelDecision
|
||||||
|
legal_basis: str | None = None
|
||||||
|
evidence_ref: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
preference_rank: int | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleListResponse(BaseModel):
|
||||||
|
rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AddressLookupResponse(BaseModel):
|
class AddressLookupResponse(BaseModel):
|
||||||
contacts: list[ContactResponse]
|
contacts: list[ContactResponse]
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
AddressSyncSource,
|
AddressSyncSource,
|
||||||
AddressSyncTombstone,
|
AddressSyncTombstone,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
ContactEmail,
|
ContactEmail,
|
||||||
ContactPhone,
|
ContactPhone,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
@@ -68,6 +69,7 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
ContactPhonePayload,
|
ContactPhonePayload,
|
||||||
ContactPostalAddressPayload,
|
ContactPostalAddressPayload,
|
||||||
ContactUpdateRequest,
|
ContactUpdateRequest,
|
||||||
|
ContactChannelRuleCreateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
||||||
|
|
||||||
@@ -2514,6 +2516,80 @@ def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: s
|
|||||||
return contact
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
def list_contact_channel_rules(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
contact_id: str,
|
||||||
|
) -> list[ContactChannelRule]:
|
||||||
|
contact = get_visible_contact(session, principal, contact_id)
|
||||||
|
return (
|
||||||
|
session.query(ContactChannelRule)
|
||||||
|
.filter(ContactChannelRule.contact_id == contact.id)
|
||||||
|
.order_by(ContactChannelRule.created_at.desc(), ContactChannelRule.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_contact_channel_rule(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
contact_id: str,
|
||||||
|
payload: ContactChannelRuleCreateRequest,
|
||||||
|
) -> ContactChannelRule:
|
||||||
|
contact = get_visible_contact(session, principal, contact_id)
|
||||||
|
if (
|
||||||
|
payload.effective_from is not None
|
||||||
|
and payload.effective_until is not None
|
||||||
|
and payload.effective_until <= payload.effective_from
|
||||||
|
):
|
||||||
|
raise AddressBookError("Channel-rule end must be after its start.")
|
||||||
|
point_ids: set[str] = set()
|
||||||
|
if payload.channel == "email":
|
||||||
|
point_ids = {item.id for item in contact.emails}
|
||||||
|
elif payload.channel == "postal":
|
||||||
|
point_ids = {item.id for item in contact.postal_addresses}
|
||||||
|
if payload.contact_point_id and payload.contact_point_id not in point_ids:
|
||||||
|
raise AddressBookError(
|
||||||
|
"The selected contact point does not belong to this contact and channel."
|
||||||
|
)
|
||||||
|
rule = ContactChannelRule(
|
||||||
|
tenant_id=contact.tenant_id,
|
||||||
|
contact_id=contact.id,
|
||||||
|
channel=payload.channel,
|
||||||
|
purpose=_trim(payload.purpose),
|
||||||
|
contact_point_id=payload.contact_point_id,
|
||||||
|
decision=payload.decision,
|
||||||
|
legal_basis=_trim(payload.legal_basis),
|
||||||
|
evidence_ref=_trim(payload.evidence_ref),
|
||||||
|
reason=_trim(payload.reason),
|
||||||
|
preference_rank=payload.preference_rank,
|
||||||
|
locale=_trim(payload.locale),
|
||||||
|
effective_from=payload.effective_from,
|
||||||
|
effective_until=payload.effective_until,
|
||||||
|
created_by_account_id=_account_id(principal),
|
||||||
|
metadata_=dict(payload.metadata),
|
||||||
|
)
|
||||||
|
session.add(rule)
|
||||||
|
session.flush()
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
def end_contact_channel_rule(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
rule_id: str,
|
||||||
|
) -> ContactChannelRule:
|
||||||
|
rule = session.get(ContactChannelRule, rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise AddressBookError("Contact channel rule not found.")
|
||||||
|
get_visible_contact(session, principal, rule.contact_id)
|
||||||
|
now = utcnow()
|
||||||
|
if rule.effective_until is None or rule.effective_until > now:
|
||||||
|
rule.effective_until = now
|
||||||
|
session.add(rule)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact:
|
def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact:
|
||||||
contact = get_visible_contact(session, principal, contact_id)
|
contact = get_visible_contact(session, principal, contact_id)
|
||||||
_require_mutable_book(contact.address_book)
|
_require_mutable_book(contact.address_book)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine, inspect
|
from sqlalchemy import create_engine, inspect
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.distribution_lists import (
|
||||||
|
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||||
|
DistributionSourceReference,
|
||||||
|
RecipientChannelFactsRequest,
|
||||||
|
)
|
||||||
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
@@ -19,6 +25,7 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||||
CAPABILITY_ADDRESSES_LOOKUP,
|
CAPABILITY_ADDRESSES_LOOKUP,
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||||
|
AddressesChannelFactsCapability,
|
||||||
AddressesContactWriterCapability,
|
AddressesContactWriterCapability,
|
||||||
AddressesLookupCapability,
|
AddressesLookupCapability,
|
||||||
AddressesPeopleSearchProvider,
|
AddressesPeopleSearchProvider,
|
||||||
@@ -34,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
|
|||||||
AddressSyncSource,
|
AddressSyncSource,
|
||||||
AddressSyncTombstone,
|
AddressSyncTombstone,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
ContactEmail,
|
ContactEmail,
|
||||||
ContactPhone,
|
ContactPhone,
|
||||||
ContactPostalAddress,
|
ContactPostalAddress,
|
||||||
@@ -52,6 +60,7 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
AddressSyncSourceUpdateRequest,
|
AddressSyncSourceUpdateRequest,
|
||||||
AddressSyncTombstoneCreateRequest,
|
AddressSyncTombstoneCreateRequest,
|
||||||
ContactCreateRequest,
|
ContactCreateRequest,
|
||||||
|
ContactChannelRuleCreateRequest,
|
||||||
ContactEmailPayload,
|
ContactEmailPayload,
|
||||||
ContactPostalAddressPayload,
|
ContactPostalAddressPayload,
|
||||||
)
|
)
|
||||||
@@ -66,10 +75,12 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_address_list_entry,
|
create_address_list_entry,
|
||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
|
create_contact_channel_rule,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
count_contacts,
|
count_contacts,
|
||||||
delete_address_list_entry,
|
delete_address_list_entry,
|
||||||
delete_contact,
|
delete_contact,
|
||||||
|
end_contact_channel_rule,
|
||||||
delete_sync_source,
|
delete_sync_source,
|
||||||
discover_carddav_address_books,
|
discover_carddav_address_books,
|
||||||
export_address_book_vcard,
|
export_address_book_vcard,
|
||||||
@@ -78,6 +89,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
list_address_lists,
|
list_address_lists,
|
||||||
list_address_books,
|
list_address_books,
|
||||||
list_contacts,
|
list_contacts,
|
||||||
|
list_contact_channel_rules,
|
||||||
list_sync_conflicts,
|
list_sync_conflicts,
|
||||||
list_sync_diagnostics,
|
list_sync_diagnostics,
|
||||||
list_sync_sources,
|
list_sync_sources,
|
||||||
@@ -147,6 +159,8 @@ class Principal:
|
|||||||
"addresses:contact:read",
|
"addresses:contact:read",
|
||||||
"addresses:contact:write",
|
"addresses:contact:write",
|
||||||
"addresses:contact:delete",
|
"addresses:contact:delete",
|
||||||
|
"addresses:governance:read",
|
||||||
|
"addresses:governance:write",
|
||||||
"addresses:sync:read",
|
"addresses:sync:read",
|
||||||
"addresses:sync:write",
|
"addresses:sync:write",
|
||||||
}
|
}
|
||||||
@@ -181,6 +195,7 @@ class AddressServiceTest(unittest.TestCase):
|
|||||||
ContactEmail.__table__,
|
ContactEmail.__table__,
|
||||||
ContactPhone.__table__,
|
ContactPhone.__table__,
|
||||||
ContactPostalAddress.__table__,
|
ContactPostalAddress.__table__,
|
||||||
|
ContactChannelRule.__table__,
|
||||||
AddressListEntry.__table__,
|
AddressListEntry.__table__,
|
||||||
AddressSyncSource.__table__,
|
AddressSyncSource.__table__,
|
||||||
AddressSyncTombstone.__table__,
|
AddressSyncTombstone.__table__,
|
||||||
@@ -381,6 +396,7 @@ END:VCARD
|
|||||||
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
|
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
|
||||||
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
|
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
|
||||||
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
|
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
|
||||||
|
self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided)
|
||||||
|
|
||||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
|
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -459,6 +475,120 @@ END:VCARD
|
|||||||
)
|
)
|
||||||
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
|
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
|
||||||
|
|
||||||
|
def test_channel_facts_are_effective_purpose_aware_and_provenance_bearing(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Governed recipients"),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
contact = create_contact(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
ContactCreateRequest(
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
emails=[ContactEmailPayload(email="ada@example.local", is_primary=True)],
|
||||||
|
postal_addresses=[
|
||||||
|
ContactPostalAddressPayload(
|
||||||
|
street="Main Street 1",
|
||||||
|
postal_code="10115",
|
||||||
|
locality="Berlin",
|
||||||
|
country="Germany",
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
rule = create_contact_channel_rule(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
contact.id,
|
||||||
|
ContactChannelRuleCreateRequest(
|
||||||
|
channel="email",
|
||||||
|
purpose="campaign_delivery",
|
||||||
|
contact_point_id=contact.emails[0].id,
|
||||||
|
decision="opted_out",
|
||||||
|
legal_basis="consent",
|
||||||
|
evidence_ref="case:consent-42",
|
||||||
|
reason="Recipient withdrew email consent.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
source = DistributionSourceReference(
|
||||||
|
provider="addresses",
|
||||||
|
resource_type="contact",
|
||||||
|
resource_id=contact.id,
|
||||||
|
)
|
||||||
|
facts = AddressesChannelFactsCapability().resolve_channel_facts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=RecipientChannelFactsRequest(
|
||||||
|
tenant_id=self.principal.tenant_id,
|
||||||
|
source=source,
|
||||||
|
recipient_key=f"contact:{contact.id}",
|
||||||
|
effective_at=utcnow() + timedelta(seconds=1),
|
||||||
|
purpose="campaign_delivery",
|
||||||
|
requested_channels=("email", "postal"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
candidates = {item.channel: item for item in facts.candidates}
|
||||||
|
self.assertEqual(candidates["email"].status, "suppressed")
|
||||||
|
self.assertEqual(candidates["email"].reason_code, "addresses.channel.opted_out")
|
||||||
|
self.assertEqual(candidates["email"].decision_provenance["selected_rule_id"], rule.id)
|
||||||
|
self.assertEqual(candidates["email"].decision_provenance["evidence_ref"], "case:consent-42")
|
||||||
|
self.assertEqual(candidates["postal"].status, "usable")
|
||||||
|
self.assertEqual(candidates["postal"].decision_provenance["governance_state"], "unknown")
|
||||||
|
self.assertTrue(facts.source_revision)
|
||||||
|
self.assertEqual(len(facts.source_fingerprint or ""), 64)
|
||||||
|
|
||||||
|
governed_snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
address_book_id=book.id,
|
||||||
|
purpose="campaign_delivery",
|
||||||
|
)
|
||||||
|
self.assertEqual(governed_snapshot.recipients, ())
|
||||||
|
self.assertEqual(len(governed_snapshot.excluded), 1)
|
||||||
|
self.assertEqual(governed_snapshot.excluded[0].reason_code, "addresses.channel.opted_out")
|
||||||
|
self.assertTrue(governed_snapshot.provenance["governance_applied"])
|
||||||
|
|
||||||
|
unrelated = AddressesChannelFactsCapability().resolve_channel_facts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=RecipientChannelFactsRequest(
|
||||||
|
tenant_id=self.principal.tenant_id,
|
||||||
|
source=source,
|
||||||
|
recipient_key=f"contact:{contact.id}",
|
||||||
|
effective_at=utcnow() + timedelta(seconds=1),
|
||||||
|
purpose="service_notice",
|
||||||
|
requested_channels=("email",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(unrelated.candidates[0].status, "usable")
|
||||||
|
self.assertEqual(unrelated.candidates[0].decision_provenance["governance_state"], "unknown")
|
||||||
|
|
||||||
|
ended = end_contact_channel_rule(self.session, self.principal, rule.id)
|
||||||
|
self.session.commit()
|
||||||
|
expired = AddressesChannelFactsCapability().resolve_channel_facts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=RecipientChannelFactsRequest(
|
||||||
|
tenant_id=self.principal.tenant_id,
|
||||||
|
source=source,
|
||||||
|
recipient_key=f"contact:{contact.id}",
|
||||||
|
effective_at=utcnow() + timedelta(seconds=1),
|
||||||
|
purpose="campaign_delivery",
|
||||||
|
requested_channels=("email",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(ended.effective_until)
|
||||||
|
self.assertEqual(expired.candidates[0].status, "usable")
|
||||||
|
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
|
||||||
|
self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id)
|
||||||
|
|
||||||
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
|
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
|
||||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
|
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
|
||||||
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
|
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
|
||||||
|
|||||||
@@ -84,6 +84,53 @@ export type Contact = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||||
|
export type AddressChannelDecision =
|
||||||
|
| "allowed"
|
||||||
|
| "opted_in"
|
||||||
|
| "preferred"
|
||||||
|
| "opted_out"
|
||||||
|
| "suppressed"
|
||||||
|
| "invalid"
|
||||||
|
| "returned"
|
||||||
|
| "temporarily_unavailable";
|
||||||
|
|
||||||
|
export type ContactChannelRule = {
|
||||||
|
id: string;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
contact_id: string;
|
||||||
|
channel: AddressDistributionChannel;
|
||||||
|
purpose?: string | null;
|
||||||
|
contact_point_id?: string | null;
|
||||||
|
decision: AddressChannelDecision;
|
||||||
|
legal_basis?: string | null;
|
||||||
|
evidence_ref?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
preference_rank?: number | null;
|
||||||
|
locale?: string | null;
|
||||||
|
effective_from?: string | null;
|
||||||
|
effective_until?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_by_account_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactChannelRulePayload = {
|
||||||
|
channel: AddressDistributionChannel;
|
||||||
|
purpose?: string | null;
|
||||||
|
contact_point_id?: string | null;
|
||||||
|
decision: AddressChannelDecision;
|
||||||
|
legal_basis?: string | null;
|
||||||
|
evidence_ref?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
preference_rank?: number | null;
|
||||||
|
locale?: string | null;
|
||||||
|
effective_from?: string | null;
|
||||||
|
effective_until?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AddressBookCreatePayload = {
|
export type AddressBookCreatePayload = {
|
||||||
scope_type: AddressBookScope;
|
scope_type: AddressBookScope;
|
||||||
group_id?: string | null;
|
group_id?: string | null;
|
||||||
@@ -341,6 +388,10 @@ type AddressSyncConflictListResponse = {
|
|||||||
conflicts: AddressSyncConflict[];
|
conflicts: AddressSyncConflict[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContactChannelRuleListResponse = {
|
||||||
|
rules: ContactChannelRule[];
|
||||||
|
};
|
||||||
|
|
||||||
function queryString(params: Record<string, string | number | null | undefined>): string {
|
function queryString(params: Record<string, string | number | null | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
@@ -626,6 +677,31 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
|
|||||||
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
|
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
|
||||||
|
const response = await apiFetch<ContactChannelRuleListResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/addresses/contacts/${contactId}/channel-rules`
|
||||||
|
);
|
||||||
|
return response.rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContactChannelRule(
|
||||||
|
settings: ApiSettings,
|
||||||
|
contactId: string,
|
||||||
|
payload: ContactChannelRulePayload
|
||||||
|
): Promise<ContactChannelRule> {
|
||||||
|
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contacts/${contactId}/channel-rules`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endContactChannelRule(settings: ApiSettings, ruleId: string): Promise<ContactChannelRule> {
|
||||||
|
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contact-channel-rules/${ruleId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
|
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
|
||||||
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
|
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
|
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -30,12 +30,14 @@ import {
|
|||||||
createAddressListEntry,
|
createAddressListEntry,
|
||||||
createCardDavSyncSource,
|
createCardDavSyncSource,
|
||||||
createContact,
|
createContact,
|
||||||
|
createContactChannelRule,
|
||||||
deleteAddressBook,
|
deleteAddressBook,
|
||||||
deleteAddressList,
|
deleteAddressList,
|
||||||
deleteAddressListEntry,
|
deleteAddressListEntry,
|
||||||
deleteContact,
|
deleteContact,
|
||||||
deleteAddressSyncSource,
|
deleteAddressSyncSource,
|
||||||
discoverCardDavAddressBooks,
|
discoverCardDavAddressBooks,
|
||||||
|
endContactChannelRule,
|
||||||
exportAddressBookVcards,
|
exportAddressBookVcards,
|
||||||
exportContactVcard,
|
exportContactVcard,
|
||||||
importAddressBookVcards,
|
importAddressBookVcards,
|
||||||
@@ -49,6 +51,7 @@ import {
|
|||||||
listAddressSyncTombstones,
|
listAddressSyncTombstones,
|
||||||
listContacts,
|
listContacts,
|
||||||
listContactsPage,
|
listContactsPage,
|
||||||
|
listContactChannelRules,
|
||||||
previewAddressSyncSource,
|
previewAddressSyncSource,
|
||||||
restoreAddressBook,
|
restoreAddressBook,
|
||||||
restoreAddressList,
|
restoreAddressList,
|
||||||
@@ -62,7 +65,9 @@ import {
|
|||||||
type AddressCardDavAddressBook,
|
type AddressCardDavAddressBook,
|
||||||
type AddressBook,
|
type AddressBook,
|
||||||
type AddressBookScope,
|
type AddressBookScope,
|
||||||
|
type AddressChannelDecision,
|
||||||
type AddressCredentialEnvelope,
|
type AddressCredentialEnvelope,
|
||||||
|
type AddressDistributionChannel,
|
||||||
type AddressList,
|
type AddressList,
|
||||||
type AddressListEntry,
|
type AddressListEntry,
|
||||||
type AddressSyncConflict,
|
type AddressSyncConflict,
|
||||||
@@ -70,7 +75,9 @@ import {
|
|||||||
type AddressSyncPlan,
|
type AddressSyncPlan,
|
||||||
type AddressSyncSource,
|
type AddressSyncSource,
|
||||||
type AddressSyncTombstone,
|
type AddressSyncTombstone,
|
||||||
type Contact
|
type Contact,
|
||||||
|
type ContactChannelRule,
|
||||||
|
type ContactChannelRulePayload
|
||||||
} from "../../api/addresses";
|
} from "../../api/addresses";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -139,6 +146,20 @@ type ContactFormState = {
|
|||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChannelRuleFormState = {
|
||||||
|
channel: AddressDistributionChannel;
|
||||||
|
purpose: string;
|
||||||
|
contact_point_id: string;
|
||||||
|
decision: AddressChannelDecision;
|
||||||
|
legal_basis: string;
|
||||||
|
evidence_ref: string;
|
||||||
|
reason: string;
|
||||||
|
preference_rank: string;
|
||||||
|
locale: string;
|
||||||
|
effective_from: string;
|
||||||
|
effective_until: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ListFormState = {
|
type ListFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -212,6 +233,20 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
|
|||||||
sync_direction: "read_only"
|
sync_direction: "read_only"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
|
||||||
|
channel: "email",
|
||||||
|
purpose: "",
|
||||||
|
contact_point_id: "",
|
||||||
|
decision: "allowed",
|
||||||
|
legal_basis: "",
|
||||||
|
evidence_ref: "",
|
||||||
|
reason: "",
|
||||||
|
preference_rank: "",
|
||||||
|
locale: "",
|
||||||
|
effective_from: "",
|
||||||
|
effective_until: ""
|
||||||
|
};
|
||||||
|
|
||||||
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
|
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
|
||||||
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
|
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
|
||||||
|
|
||||||
@@ -613,6 +648,13 @@ function downloadText(filename: string, content: string, type = "text/vcard;char
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelRuleState(rule: ContactChannelRule): "active" | "scheduled" | "ended" {
|
||||||
|
const now = Date.now();
|
||||||
|
if (rule.effective_until && new Date(rule.effective_until).getTime() <= now) return "ended";
|
||||||
|
if (rule.effective_from && new Date(rule.effective_from).getTime() > now) return "scheduled";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
function disabledReason(...conditions: Array<[boolean, string]>): string {
|
function disabledReason(...conditions: Array<[boolean, string]>): string {
|
||||||
return conditions.find(([applies]) => applies)?.[1] ?? "";
|
return conditions.find(([applies]) => applies)?.[1] ?? "";
|
||||||
}
|
}
|
||||||
@@ -642,6 +684,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
|
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
|
||||||
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
|
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
|
||||||
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
|
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
|
||||||
|
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
|
||||||
|
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
|
||||||
|
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
|
||||||
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
||||||
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
||||||
const [memberQuery, setMemberQuery] = useState("");
|
const [memberQuery, setMemberQuery] = useState("");
|
||||||
@@ -670,6 +715,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const canDeleteLists = hasScope(auth, "addresses:address_list:delete");
|
const canDeleteLists = hasScope(auth, "addresses:address_list:delete");
|
||||||
const canWriteContacts = hasScope(auth, "addresses:contact:write");
|
const canWriteContacts = hasScope(auth, "addresses:contact:write");
|
||||||
const canDeleteContacts = hasScope(auth, "addresses:contact:delete");
|
const canDeleteContacts = hasScope(auth, "addresses:contact:delete");
|
||||||
|
const canReadGovernance = hasScope(auth, "addresses:governance:read");
|
||||||
|
const canWriteGovernance = hasScope(auth, "addresses:governance:write");
|
||||||
const canReadSync = hasScope(auth, "addresses:sync:read");
|
const canReadSync = hasScope(auth, "addresses:sync:read");
|
||||||
const canWriteSync = hasScope(auth, "addresses:sync:write");
|
const canWriteSync = hasScope(auth, "addresses:sync:write");
|
||||||
|
|
||||||
@@ -744,6 +791,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
});
|
});
|
||||||
}, [memberCandidates, memberQuery, selectedListContactIds]);
|
}, [memberCandidates, memberQuery, selectedListContactIds]);
|
||||||
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null;
|
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null;
|
||||||
|
const governancePointOptions = useMemo(() => {
|
||||||
|
if (!governanceContact) return [];
|
||||||
|
if (channelRuleForm.channel === "email") {
|
||||||
|
return governanceContact.emails
|
||||||
|
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
|
||||||
|
.map((item) => ({ id: item.id, label: `${item.label || "email"}: ${item.email}` }));
|
||||||
|
}
|
||||||
|
if (channelRuleForm.channel === "postal") {
|
||||||
|
return governanceContact.postal_addresses
|
||||||
|
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
|
||||||
|
.map((item) => ({ id: item.id, label: `${item.label || "address"}: ${formatPostalAddress(item) || "Unformatted address"}` }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [channelRuleForm.channel, governanceContact]);
|
||||||
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
|
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
|
||||||
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
|
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
|
||||||
const loadingReason = loading ? "Address books are loading." : "";
|
const loadingReason = loading ? "Address books are loading." : "";
|
||||||
@@ -827,6 +888,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
[saving, savingReason],
|
[saving, savingReason],
|
||||||
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."]
|
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."]
|
||||||
);
|
);
|
||||||
|
const channelRuleSaveReason = disabledReason(
|
||||||
|
[saving, savingReason],
|
||||||
|
[!governanceContact, "Select a contact before recording a governance fact."],
|
||||||
|
[!canWriteGovernance, "You need permission to manage communication governance."],
|
||||||
|
[
|
||||||
|
Boolean(
|
||||||
|
channelRuleForm.effective_from
|
||||||
|
&& channelRuleForm.effective_until
|
||||||
|
&& new Date(channelRuleForm.effective_until) <= new Date(channelRuleForm.effective_from)
|
||||||
|
),
|
||||||
|
"The end of the effective period must be after its start."
|
||||||
|
]
|
||||||
|
);
|
||||||
const dialogCancelReason = disabledReason([saving, savingReason]);
|
const dialogCancelReason = disabledReason([saving, savingReason]);
|
||||||
const vcardImportReason = disabledReason(
|
const vcardImportReason = disabledReason(
|
||||||
[saving, savingReason],
|
[saving, savingReason],
|
||||||
@@ -1121,6 +1195,71 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
setContactDialog({ mode: "edit", contact });
|
setContactDialog({ mode: "edit", contact });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openGovernanceDialog(contact: Contact) {
|
||||||
|
setGovernanceContact(contact);
|
||||||
|
setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM);
|
||||||
|
setChannelRules([]);
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setChannelRules(await listContactChannelRules(settings, contact.id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
setGovernanceContact(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChannelRule(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!governanceContact || channelRuleSaveReason) return;
|
||||||
|
const toIso = (value: string) => value ? new Date(value).toISOString() : null;
|
||||||
|
const payload: ContactChannelRulePayload = {
|
||||||
|
channel: channelRuleForm.channel,
|
||||||
|
purpose: channelRuleForm.purpose.trim() || null,
|
||||||
|
contact_point_id: channelRuleForm.contact_point_id || null,
|
||||||
|
decision: channelRuleForm.decision,
|
||||||
|
legal_basis: channelRuleForm.legal_basis.trim() || null,
|
||||||
|
evidence_ref: channelRuleForm.evidence_ref.trim() || null,
|
||||||
|
reason: channelRuleForm.reason.trim() || null,
|
||||||
|
preference_rank: channelRuleForm.preference_rank ? Number(channelRuleForm.preference_rank) : null,
|
||||||
|
locale: channelRuleForm.locale.trim() || null,
|
||||||
|
effective_from: toIso(channelRuleForm.effective_from),
|
||||||
|
effective_until: toIso(channelRuleForm.effective_until),
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await createContactChannelRule(settings, governanceContact.id, payload);
|
||||||
|
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
|
||||||
|
setChannelRuleForm((current) => ({ ...EMPTY_CHANNEL_RULE_FORM, channel: current.channel }));
|
||||||
|
setNotice("Communication-governance fact recorded.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endChannelRule(rule: ContactChannelRule) {
|
||||||
|
if (!governanceContact || saving || !canWriteGovernance) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await endContactChannelRule(settings, rule.id);
|
||||||
|
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
|
||||||
|
setNotice("Communication-governance fact ended; its history was retained.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
|
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
|
||||||
setContactForm((current) => ({
|
setContactForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1901,6 +2040,17 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedContact.deleted_at ?
|
{selectedContact.deleted_at ?
|
||||||
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
|
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
title="Communication governance"
|
||||||
|
aria-label="Communication governance"
|
||||||
|
disabledReason={disabledReason(
|
||||||
|
[!canReadGovernance, "You need permission to view communication governance."],
|
||||||
|
[saving, savingReason]
|
||||||
|
)}
|
||||||
|
onClick={() => void openGovernanceDialog(selectedContact)}>
|
||||||
|
<ShieldCheck size={15} /> Governance
|
||||||
|
</Button>
|
||||||
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||||
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||||
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||||
@@ -2235,6 +2385,128 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(governanceContact)}
|
||||||
|
title={governanceContact ? `Communication governance · ${governanceContact.display_name}` : "Communication governance"}
|
||||||
|
onClose={() => setGovernanceContact(null)}
|
||||||
|
closeDisabled={saving}
|
||||||
|
className="address-governance-dialog"
|
||||||
|
footerClassName="button-row compact-actions"
|
||||||
|
footer={<Button type="button" onClick={() => setGovernanceContact(null)} disabledReason={dialogCancelReason}>Close</Button>}>
|
||||||
|
<div className="address-governance-layout">
|
||||||
|
<section className="address-form-section">
|
||||||
|
<div className="address-form-section-heading">
|
||||||
|
<div>
|
||||||
|
<strong>Recorded facts</strong>
|
||||||
|
<p className="muted small-text">History is retained. End a fact when it no longer applies.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="address-governance-list">
|
||||||
|
{channelRules.length === 0 ?
|
||||||
|
<p className="muted">No communication-governance facts recorded.</p> :
|
||||||
|
channelRules.map((rule) => {
|
||||||
|
const state = channelRuleState(rule);
|
||||||
|
return (
|
||||||
|
<article className="address-governance-rule" key={rule.id}>
|
||||||
|
<div className="address-governance-rule-main">
|
||||||
|
<span className="address-governance-rule-heading">
|
||||||
|
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")}</strong>
|
||||||
|
<StatusBadge status={state} />
|
||||||
|
</span>
|
||||||
|
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
|
||||||
|
<small>
|
||||||
|
{rule.legal_basis ? `Basis: ${rule.legal_basis}` : "No legal basis recorded"}
|
||||||
|
{rule.evidence_ref ? ` · Evidence: ${rule.evidence_ref}` : ""}
|
||||||
|
{rule.effective_from ? ` · From ${formatDateTime(rule.effective_from, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
|
||||||
|
{rule.effective_until ? ` · Until ${formatDateTime(rule.effective_until, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{state !== "ended" &&
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabledReason={disabledReason(
|
||||||
|
[!canWriteGovernance, "You need permission to manage communication governance."],
|
||||||
|
[saving, savingReason]
|
||||||
|
)}
|
||||||
|
onClick={() => void endChannelRule(rule)}>
|
||||||
|
<X size={15} /> End
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canWriteGovernance &&
|
||||||
|
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
|
||||||
|
<div className="address-form-section-heading">
|
||||||
|
<div>
|
||||||
|
<strong>Record a fact</strong>
|
||||||
|
<p className="muted small-text">Addresses records evidence; Policy may apply stricter delivery rules.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<FormField label="Channel">
|
||||||
|
<select
|
||||||
|
value={channelRuleForm.channel}
|
||||||
|
onChange={(event) => setChannelRuleForm((current) => ({
|
||||||
|
...current,
|
||||||
|
channel: event.target.value as AddressDistributionChannel,
|
||||||
|
contact_point_id: ""
|
||||||
|
}))}>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="postal">Postal mail</option>
|
||||||
|
<option value="internal_mail">Internal mail</option>
|
||||||
|
<option value="portal">Portal</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Contact point">
|
||||||
|
<select
|
||||||
|
value={channelRuleForm.contact_point_id}
|
||||||
|
disabled={governancePointOptions.length === 0}
|
||||||
|
onChange={(event) => setChannelRuleForm((current) => ({ ...current, contact_point_id: event.target.value }))}>
|
||||||
|
<option value="">All {channelRuleForm.channel.replace("_", " ")} contact points</option>
|
||||||
|
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<FormField label="Decision">
|
||||||
|
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
|
||||||
|
<option value="allowed">Allowed</option>
|
||||||
|
<option value="opted_in">Opted in</option>
|
||||||
|
<option value="preferred">Preferred</option>
|
||||||
|
<option value="opted_out">Opted out</option>
|
||||||
|
<option value="suppressed">Suppressed</option>
|
||||||
|
<option value="invalid">Invalid</option>
|
||||||
|
<option value="returned">Returned</option>
|
||||||
|
<option value="temporarily_unavailable">Temporarily unavailable</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Communication purpose">
|
||||||
|
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid three">
|
||||||
|
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
|
||||||
|
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
|
||||||
|
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid three">
|
||||||
|
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
|
||||||
|
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
|
||||||
|
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Reason"><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={memberDialogOpen}
|
open={memberDialogOpen}
|
||||||
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
||||||
|
|||||||
@@ -539,6 +539,51 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-governance-dialog .dialog-panel {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-layout,
|
||||||
|
.address-governance-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-list {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-main,
|
||||||
|
.address-governance-rule-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-heading {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.address-form-row-email,
|
.address-form-row-email,
|
||||||
.address-form-row-phone {
|
.address-form-row-phone {
|
||||||
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
|
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
|
||||||
|
|||||||
Reference in New Issue
Block a user